# A copilot for your team with your own data

**You will:** Build a data connector with the SDK helpers, validate it with the CLI, register it and let an internal copilot answer your team from it.

**You need:**
- An API key with connectors:read, connectors:write, connectors:reveal, connectors:audit-read, agents:write, agents:internal, pipelines:write, cases:write and cases:converse
- An https API of your back office (a local http one works in a development API)
- Node.js 20 or later

Your collections desk takes a call from a customer. Instead of opening three screens, a colleague asks the copilot: what does this customer owe and which invoices are open? The copilot reads the account card and the open invoices live from your system through a connector, answers, and never sees an email, a phone or a RUT: those come back masked. When a person needs one value, a key with `connectors:reveal` reads it and the audit records who looked.

**Who it is for.** Teams that answer customers from a back office (support, collections, operations). **Scopes.** As in the box above. The `agents:internal` scope is sensitive: a copilot reads personal data of your customers through its tools.

**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.

The files of every step are in [`examples/use-cases/internal-copilot`](https://github.com/Lelemon-studio/lelemon-app/tree/main/apps/docs/examples/use-cases/internal-copilot).

## 0. Set up

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

const lelemon = new Lelemon({ apiKey: process.env.LELEMON_API_KEY });
```

## 1. Describe your back office in the connector format

Four documents are enough: a manifest (what the connector offers), the card of an account, the answer of an account search by email and a view page. The SDK has a helper for each, so the fixed parts (`format`, `object`, `type`, `hasMore`) are filled for you. Mark personal data with `sensitive: true`; Lelemon also masks anything shaped like a RUT, email or phone as a fallback. The `description` of each view is written for the model: it decides when to read it.

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

const manifest = connector.manifest({
  name: 'Almacén Central back office',
  lookup: { byId: true, byEmail: true },
  views: [
    {
      id: 'open_invoices',
      name: 'Open invoices',
      description: 'Invoices of one customer that are not paid yet, oldest first.',
      inputSchema: {
        type: 'object',
        properties: { accountId: { type: 'string', description: 'The id of the customer account, such as acc_1042.' } },
        required: ['accountId'],
      },
    },
  ],
});

const accounts = [
  connector.account({
    id: 'acc_1042',
    title: 'Restaurante La Higuera',
    subtitle: 'Customer since 2023, Providencia',
    sections: [
      {
        title: 'Contract',
        components: [
          connector.field({ key: 'plan', label: 'Plan', valueType: 'text', value: 'Wholesale 30 days' }),
          connector.field({ key: 'credit_line', label: 'Credit line', valueType: 'money', currency: 'CLP', value: 2500000 }),
          connector.field({ key: 'balance', label: 'Balance due', valueType: 'money', currency: 'CLP', value: 684200 }),
          connector.badge({ label: 'Payment 12 days late', tone: 'warning' }),
        ],
      },
      {
        title: 'Contact',
        components: [
          connector.field({ key: 'contact_name', label: 'Contact', valueType: 'text', value: 'Ana Vera' }),
          connector.field({ key: 'contact_email', label: 'Email', valueType: 'text', value: 'ana@lahiguera.example', sensitive: true }),
          connector.field({ key: 'contact_phone', label: 'Phone', valueType: 'text', value: '+56 9 5550 0142', sensitive: true }),
          connector.field({ key: 'tax_id', label: 'RUT', valueType: 'text', value: '76.123.456-7', sensitive: true }),
          connector.action({ id: 'open_in_erp', label: 'Open in the ERP', url: 'https://admin.example.com/erp/customers/acc_1042' }),
        ],
      },
    ],
  }),
];

const openInvoices = connector.viewPage({
  columns: [
    { key: 'number', label: 'Invoice', valueType: 'text' },
    { key: 'issued', label: 'Issued', valueType: 'date' },
    { key: 'total', label: 'Total', valueType: 'money' },
    { key: 'status', label: 'Status', valueType: 'status' },
  ],
  data: [
    { id: 'inv_8812', cells: { number: 'F-8812', issued: '2026-09-18', total: 412200, status: 'overdue' } },
    { id: 'inv_8934', cells: { number: 'F-8934', issued: '2026-10-09', total: 272000, status: 'open' } },
  ],
});
```

An `action` component is an https link that the person opens; its host must be your base URL host or one of the `allowedOrigins` of the connector.

## 2. Check it with the CLI

To try a connector without a server, describe it in a fixture (`manifest`, `accounts` with an optional `email`, and `views` by id) and serve it. The [fixture of this example](https://github.com/Lelemon-studio/lelemon-app/blob/main/apps/docs/examples/use-cases/internal-copilot/fixture.json) is the same data as the documents above.

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

Output of the second command, from a real run:

```text
ok    manifest  http://127.0.0.1:4785/manifest
ok    view  http://127.0.0.1:4785/views/open_invoices
ok    account  http://127.0.0.1:4785/accounts/acc_1042
```

A document that breaks the format prints each failing path and the command exits `1`. A view id with a space and a capital letter, for example:

```text
fail  manifest  ./broken-manifest.json
        $.views[0].id  does not match ^[a-z][a-z0-9_]{0,39}$
```

The server of the CLI does not check signatures. Your real server must. This is the same one in about thirty lines, verifying the `Lelemon-Signature` of every call:

```ts title="Node.js: your server"
import { createServer } from 'node:http';
import { createReplayGuard, verifyRequest } from '@lelemon-ai/sdk';

const secrets: string[] = [];
const isReplay = createReplayGuard();

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://127.0.0.1:4783');
  try {
    await verifyRequest(req.headers, 'GET', url.toString(), '', secrets, { domain: 'connector', isReplay });
  } catch {
    res.writeHead(401).end();
    return;
  }
  const email = url.searchParams.get('email');
  const answers: Record<string, unknown> = {
    '/manifest': manifest,
    '/accounts': connector.accountList({
      data: accounts.filter(() => email === 'ana@lahiguera.example').map(({ id, title, subtitle }) => ({ id, title, subtitle })),
    }),
    '/accounts/acc_1042': accounts[0],
    '/views/open_invoices': openInvoices,
  };
  const body = answers[url.pathname];
  res.writeHead(body ? 200 : 404, { 'content-type': 'application/json' });
  res.end(JSON.stringify(body ?? { error: 'not_found' }));
});
server.listen(4783, '127.0.0.1');
```

## 3. Register and check the connector

The base URL must be https and public in production, and its site must be allowed for your key. `allowedOrigins` lists the extra hosts your `action` links point to. The check calls your manifest, validates it and makes the connector `active`.

```bash title="cURL"
curl https://api.lelemon.cl/v2/data-connectors \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: connector-1" \
  --json '{ "name": "Almacén", "baseUrl": "https://admin.example.com/lelemon", "allowedOrigins": ["https://admin.example.com"] }'

curl https://api.lelemon.cl/v2/data-connectors/dcn_.../signing-secret \
  -H "Authorization: Bearer $LELEMON_API_KEY"

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

```ts title="Node.js"
const registered = await lelemon.dataConnectors.create({
  name: 'Almacén',
  baseUrl: 'http://127.0.0.1:4783',
  allowedOrigins: ['https://admin.example.com'],
});
const signing = await lelemon.dataConnectors.retrieveSigningSecret(registered.id);
secrets.push(...signing.secrets);
const check = await lelemon.dataConnectors.check(registered.id);
```


```text
connector {"id":"dcn_7RX4iJnYI94CaTXs0zSwAV","status":"pending"}
    check {"ok":true,"problems":[],"status":"active","views":1}
```

Give the secrets of `GET /v2/data-connectors/{id}/signing-secret` to your server: it uses them in `verifyRequest`. See [Connect your back office](/guides/data-connectors).

## 4. Write the copilot

An internal agent answers your team, never customers. Its prompt tells it the order of an answer, the exact numbers rule, what to do with masked values and what is outside its data.

```bash title="cURL"
curl https://api.lelemon.cl/v2/agents \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: nico-agent-1" \
  --json '{
    "audience": "internal",
    "name": "Nico",
    "instructions": "$(cat instructions.md)",
    "llmConfig": { "model": "anthropic/claude-sonnet-5.5", "temperature": 0 },
    "channels": ["api"]
  }'
```

```ts title="Node.js"
const instructions = `You are Nico, the internal copilot of the collections desk at Almacén Central, a wholesale food distributor in Chile. You answer the people of our team, never customers.

## Goal
Give the person on the desk what they need to handle a customer call in one answer: who the customer is, what they owe, which invoices are open and what is unusual.

## How you work
- Start from what you were given. With an email, use the account search tool. With an account id, read the card directly.
- Read the card, then the open invoices view of that account. Do not guess an account: if the search returns more than one, list them and ask which one.
- Answer in this order: customer and status, balance and credit line, open invoices (number, issue date, total, overdue or not), then anything unusual.
- Use the exact numbers you read, in Chilean pesos with thousands separators. Never round and never compute totals you were not given; if you add up invoices, say that you did.

## Personal data
- Some values come back masked (value null, masked true). Say that the value is masked and that a person with reveal access can see it. Never try to reconstruct it from other fields or from your memory.
- Never repeat an email, a phone or a RUT in your answer, even when you can see it. Say which field it is.

## Limits
- You can read. You cannot change an account, send messages to customers or promise a payment plan.
- If the person asks for something outside the connector (payments received today, credit decisions), say it is not in your data and who to ask: the collections lead.
- Answer in Spanish, direct, in at most ten lines. No emojis.`;
const copilot = await lelemon.agents.create({
  audience: 'internal',
  name: 'Nico',
  description: 'Collections desk copilot',
  instructions,
  llmConfig: { model: 'anthropic/claude-sonnet-5.5', temperature: 0 },
  channels: ['api'],
});
```


`audience` cannot be changed later, and an internal agent cannot come from a template.

## 5. Grant the connector tools

An active connector gives three tools to internal agents: find an account by email, read a card and read each view. Their ids start with `dct_`. They are `noPersist`: the result lives only in the turn, and traces keep only status and duration.

```bash title="cURL"
curl "https://api.lelemon.cl/v2/agents/agt_.../tools?limit=100" \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl -X PUT https://api.lelemon.cl/v2/agents/agt_.../tools/dct_... \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const tools = await lelemon.agents.tools.list(copilot.id, { query: { limit: 100 } });
const connectorTools = tools.data.filter((t) => t.kind === 'data_connector_tool');
for (const tool of connectorTools) await lelemon.agents.tools.grant(copilot.id, tool.id);
```


```text
tools ["dct_6tybfZs0STGzBfv5y8j4n1 almacen_mvxcm844_find_account","dct_5LOxxzUkTPXR8m3mj8BaJH almacen_mvxcm844_view_open_invoices","dct_27Nu7hCrxce9QMCUnGjyCk almacen_mvxcm844_account"]
```

Once a turn has read connector data, the copilot gets no tool that writes or calls another system for the rest of that turn. This is the defense against an instruction hidden in your data.

## 6. Give it a place to work and ask

A copilot belongs to no stage, so its case needs a pipeline of its own to live in. Open the case with `agentId` and `pipelineId`, and write as the colleague.

```bash title="cURL"
curl https://api.lelemon.cl/v2/pipelines \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: desk-pipeline-1" \
  --json '{ "name": "Collections desk", "stages": [{ "name": "Desk", "entry": true }] }'

curl https://api.lelemon.cl/v2/cases \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: desk-case-1" \
  --json '{ "contactId": "ctc_...", "agentId": "agt_...", "pipelineId": "ppl_..." }'

curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: desk-msg-1" \
  --json '{ "author": "contact", "text": "Ana Vera, de la cuenta acc_1042, escribió que su pago no aparece. ¿Qué debe y qué facturas tiene abiertas? Pásame su teléfono para llamarla." }'
```

```ts title="Node.js"
const pipeline = await lelemon.pipelines.create({
  name: 'Collections desk',
  stages: [{ name: 'Desk', entry: true }],
});
const activated = await lelemon.agents.activate(copilot.id);

const colleague = await lelemon.contacts.create({ name: 'Valentina Mora', externalRef: `staff-` });
const kase = await lelemon.cases.create({ contactId: colleague.id, agentId: copilot.id, pipelineId: pipeline.id });
await lelemon.caseMessages.create(kase.id, {
  author: 'contact',
  text: 'Ana Vera, de la cuenta acc_1042, escribió que su pago no aparece. ¿Qué debe y qué facturas tiene abiertas? Pásame su teléfono para llamarla.',
});
const thread = await lelemon.caseMessages.list(kase.id, { query: { order: 'asc' } });
const turns = await lelemon.caseTurns.list(kase.id);
```


The copilot reads the account card and the open invoices view through its connector tools, and answers on the case. This is the real answer of a run, with what your back office saw and the tools the turn used:

```text
copilot said **Restaurante La Higuera (acc_1042)**, cliente desde 2023, Providencia. Plan Wholesale 30 días. Estado: pago con 12 días de atraso.

- **Saldo adeudado:** $684.200 CLP. Línea de crédito: $2.500.000 CLP.
- **Facturas abiertas (2):**
  - F-8812, emitida 18-09-2026, $412.200, vencida.
  - F-8934, emitida 09-10-2026, $272.000, abierta (no vencida).
- Sumé las dos facturas y coinciden con el saldo adeudado.
- **Inusual:** F-8812 está vencida y el pago figura con 12 días de atraso. No tengo datos de pagos recibidos, así que no puedo confirmar si el pago que menciona Ana fue registrado. Consulta con el lead de cobranza.
- **Teléfono:** está enmascarado en el campo Phone. Una persona con acceso a revelar datos puede verlo. No lo puedo mostrar.
    back office saw ["200 /manifest","200 /accounts?email=ana%40lahiguera.example","200 /accounts/acc_1042","200 /views/open_invoices?accountId=acc_1042"]
    tools used [{"tool":"almacen_mvxcm844_account","status":"ok"},{"tool":"almacen_mvxcm844_view_open_invoices","status":"ok"}]
```

The copilot answers on the case like any agent, with the same messages and events, and the case shows in the dashboard. `GET /v2/cases/{id}/turns` lists each connector tool it called with its `status`: the data it read is never stored, but a read that failed shows `status: "error"`. Look up the account id by email yourself with the same connector; the email is only for searching, so store the id as the `externalRef` of the contact:

```bash title="cURL"
curl "https://api.lelemon.cl/v2/data-connectors/dcn_.../accounts?email=ana%40lahiguera.example" \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const found = await lelemon.dataConnectors.accounts.search(registered.id, { query: { email: 'ana@lahiguera.example' } });
```


```text
accounts [{"object":"account_summary","id":"acc_1042","title":"Restaurante La Higuera","subtitle":"Customer since 2023, Providencia"}]
```

## 7. Personal data is masked, and a reveal is audited

Read the card yourself. The `sensitive` fields come back with `value: null`, `masked: true` and a `maskReason`; the copilot sees exactly this.

```bash title="cURL"
curl https://api.lelemon.cl/v2/data-connectors/dcn_.../accounts/acc_1042 \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl https://api.lelemon.cl/v2/data-connectors/dcn_.../accounts/acc_1042/reveal \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: reveal-1" \
  --json '{ "fieldKey": "contact_phone" }'

curl "https://api.lelemon.cl/v2/data-connectors/dcn_.../accesses?limit=10" \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const card = await lelemon.dataConnectors.accounts.retrieve(registered.id, 'acc_1042');
const contactSection = card.sections.find((section) => section.title === 'Contact');

const phone = await lelemon.dataConnectors.accounts.reveal(registered.id, 'acc_1042', { fieldKey: 'contact_phone' });
const accesses = await lelemon.dataConnectors.accesses.list(registered.id, { query: { limit: 10 } });
```


```text
contact section [{"type":"field","key":"contact_name","label":"Contact","valueType":"text","value":"Ana Vera","masked":false,"revealable":false},{"type":"field","key":"contact_email","label":"Email","valueType":"text","value":null,"masked":true,"maskReason":"sensitive","revealable":true},{"type":"field","key":"contact_phone","label":"Phone","valueType":"text","value":null,"masked":true,"maskReason":"sensitive","revealable":true},{"type":"field","key":"tax_id","label":"RUT","valueType":"text","value":null,"masked":true,"maskReason":"sensitive","revealable":true},{"type":"action","id":"open_in_erp","label":"Open in the ERP","url":"https://admin.example.com/erp/customers/acc_1042"}]

revealed {"key":"contact_phone","value":"+56 9 5550 0142"}
    audit [{"kind":"reveal","actor":"api_key","view":null,"field":"contact_phone","integrity":"intact"},{"kind":"read_account","actor":"api_key","view":null,"field":null,"integrity":"intact"},{"kind":"read_view","actor":"member","view":"open_invoices","field":null,"integrity":"intact"},{"kind":"read_account","actor":"member","view":null,"field":null,"integrity":"intact"},{"kind":"search_accounts","actor":"api_key","view":null,"field":null,"integrity":"intact"},{"kind":"read_manifest","actor":"api_key","view":null,"field":null,"integrity":"intact"}]
```

`reveal` reads the card live again and returns the real value of one field. The audit row is written before the value is returned, with the key, the account as a keyed hash and the field, never the value. Fields marked `specialCategory` cannot be revealed (`403`). Rows are signed and kept for two years; one that no longer matches shows `integrity: "tampered"`.

## Notes

- The connector is read live. Point it at a staging copy of your back office if the data is real.
- Registering an `http` connector works only in a development API. Production needs https and an allowed site.

## What you see in the dashboard

- **Connectors:** the connector with its status, its views and the audit of reads, reveals and action clicks.
- **Inbox:** the case of the copilot with the colleague's question and the answer.
- **Agent, Tools:** the three `dct_` tools granted to the copilot.

## What's next

- [Connect your back office](/guides/data-connectors): limits, rotating the signing secret and the format reference.
- [Keep tool data out of storage](/guides/no-persist).
- [Erase a person](/guides/erasure) when they ask.
