# Actions and MCP in v2

**You will:** Give an agent your own HTTP endpoints and MCP servers, and operate Lelemon from an MCP client.

**You need:**
- A key with actions:write
- A public endpoint or MCP server of yours

There are two different things called MCP here. Read the one you need.

- **Your tools for an agent.** You register an [action](#actions) (an HTTP endpoint of yours) or an [MCP server](#mcp-servers-your-agents-call) and grant it to an agent. The agent calls it during a conversation.
- **The Lelemon API for your agent.** An MCP client such as a coding agent or a copilot [operates Lelemon](/tools/mcp) through a server generated from the contract.

**One turn of an agent**

A contact writes, the agent calls your HTTP action and your MCP server with the context of the turn, and answers. Your backend learns about each step by webhook.

1. The contact writes. A case opens or continues and the agent starts a turn.
2. The webhook message.received tells your backend that someone wrote. It carries no text, only where to read it. Its Lelemon-Signature covers the raw body.
3. The agent calls your action. The call is signed in Lelemon-Signature and carries Lelemon-Case-Id, Lelemon-Contact-Id, Lelemon-Tool-Id and an Idempotency-Key.
4. Your backend verifies the signature with verifyRequest, does the work and answers with the result the agent reads.
5. The agent calls a tool of your MCP server. The context travels in _meta (cl.lelemon/case-id, cl.lelemon/contact-id and more) and in the claims of a signed JWT.
6. Your MCP server answers. Trust the JWT, not _meta, which is informational.
7. The agent writes the reply to the contact on the channel where they wrote.
8. Two more webhooks close the turn: message.created with the reply and case.turn_completed when the agent is done. Read the steps it took with GET /v2/cases/{id}/turns.

## Actions

An action is one HTTP call of yours that an agent can make. The agent reads `description` to decide when to use it and fills `input` before it calls.

```bash title="Create an action"
curl https://api.lelemon.cl/v2/actions \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: create-order-action-1" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "create_order",
    "description": "Create an order when the customer confirms what they want to buy.",
    "request": {
      "method": "POST",
      "url": "https://api.mesa.cl/lelemon/orders",
      "headers": { "Authorization": "Bearer your-secret" }
    },
    "input": {
      "type": "object",
      "required": ["items"],
      "properties": { "items": { "type": "array", "items": { "type": "string" } } }
    }
  }'
```


An action is this object. Open it to see its fields:

**Action**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `action` |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `description` | string | yes |  |
| `request` | ActionRequest | yes |  |
| `input` | object | yes |  |
| `audience` | enum | yes | `all`, `customers`, `internal` |
| `noPersist` | boolean | yes | When true, what this action returns lives only in the turn that called it. It is not kept in the tool memory between turns, in the execution trace, in approval requests or in run steps. The agent still uses it to answer. Consequences: the action cannot be replayed from a stored result, and a run interrupted in the middle of a round that called it fails instead of resuming. The provider (Bedrock) still receives the content as part of the prompt of that turn. |
| `sharedWithSubaccounts` | boolean | yes | Platforms only. When true, every subaccount of yours can grant this action to its own agents. The action keeps living in your organization: calls to it are signed with your signing secret and carry the Lelemon-Subaccount-Id of the subaccount that made the call. Subaccounts cannot read, change or delete it. Turning it off, or deleting the action, removes it from the agents of your subaccounts. Its name cannot match an action that one of your subaccounts already has. |
| `variables` | ActionVariable[] | yes | The fields Lelemon fills on every call. Empty when there are none. |
| `authorization` | ActionAuthorizationRecord or null | yes | The authorization in force for the destination of this action: what you accepted when you created it or last changed its destination. null when there is none, for example after the destination changed and was not authorized again, and always null on an action shared by your platform. |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |

- The URL must be public `http` or `https`. Its site must be allowed for the API key; an administrator adds sites to the key. Otherwise the answer is `403 action_origin_not_allowed`.
- Credentials go in `request.headers`. They are stored encrypted and never come back, only the header names do.
- The URL is called exactly as written. A placeholder such as `{id}` is rejected with `400` on `request.url`: what changes per call goes in `input` (the query for `GET`, the body otherwise).
- The optional `authorization` block (`purpose`, `destinationCountry`, `version`, and `confirmsTransferOutsideChile` outside Chile) records the authorization of article 15 bis of Law 21.719. Read the text and its version with `GET /v2/actions/authorization`. Send all of the block or none of it; `PATCH` accepts it too.
- The action does nothing until you grant it to an agent with `PUT /v2/agents/{id}/tools/{toolId}`. Use `audience: customers` for an action that agents talking to the public may use.
- `POST /v2/actions/{id}/test` calls your endpoint once with sample input. It really calls it: use sample data when the action writes. The call carries `Lelemon-Signature`, the context headers and an `Idempotency-Key` like a call of a turn, so the code that verifies the signature is tested too. It has no case, contact or actor.

## MCP servers your agents call

An MCP server of yours gives an agent a set of tools. It must be a public Streamable HTTP server; OAuth servers are connected from the dashboard.

```bash title="Connect a server and look at its tools"
curl https://api.lelemon.cl/v2/mcp-servers \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: crm-mcp-1" \
  -H "Content-Type: application/json" \
  -d '{ "name": "crm", "url": "https://mcp.mesa.cl/mcp", "auth": { "type": "bearer", "token": "your-secret" } }'

curl -X POST https://api.lelemon.cl/v2/mcp-servers/$SERVER_ID/discover \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: crm-mcp-discover-1"
```


`POST /v2/mcp-servers/{id}/discover` answers the server, not a list: its tools come in `tools`. For a paginated list use `GET /v2/mcp-servers/{id}/tools`. The `id` of each tool (`mtl_...`) is its `toolId`. Grant the ones an agent may use with `PUT /v2/agents/{id}/tools/{toolId}`. A server whose last tool call was rejected or unanswered shows `status: "error"`. It goes back to `connected` as soon as a discovery succeeds, which `PATCH /v2/mcp-servers/{id}` with new credentials or a new URL runs for you, or on the next tool call that works. If the server stops answering, `422 mcp_server_unreachable` tells you to check the URL and the credentials; Lelemon does not follow redirects.

### Connect a code repository with the GitHub MCP server

To let an agent read your code, connect the GitHub MCP server in read-only mode. No credentials go in a repository: you paste the token in the dashboard, where it is stored encrypted.

1. In GitHub, create a fine-grained token that can only read the repositories you want to share, with read access to **Contents** and **Metadata**.
2. In **Integrations > MCP**, press **Conectar tu sistema**. Use the name `GitHub`, the address `https://api.githubcopilot.com/mcp/readonly`, authentication **Con un token**, and paste the token.
3. In **Asignar a agentes**, check the tools you want for each agent, for example the ones that search code and read files.

The address ends in `/readonly`, so GitHub only serves its read tools. The same steps work for the MCP server of a SaaS your team uses: connect it from **Integrations > MCP** (the catalog connects the ones with OAuth) and check its tools for the agents that need it.

## System tools: hand off to a person and write case fields

Some tools come with Lelemon and you grant them like any other. Their `toolId` is their name.

- `transfer_to_human` hands the case to a person. The case moves to human control, goes to the handoff stage and fires `case.escalated`.
- `save_case_data` lets the agent write what the contact says into the case fields whose `source` is `agent`.
- `send_buttons`, `send_list`, `send_products`, `send_carousel`, `send_cta_url`, `send_flow` and `request_location` let the agent send interactive WhatsApp messages: up to 3 reply buttons, a menu of 4 to 10 options, products from the catalog connected to the number, swipeable cards, a link button, a WhatsApp Flow or a request for a location. They take no `config`. What the person answers (the button, the cart, the location or the Flow fields) arrives in the next turn of the agent. They only work on WhatsApp and within 24 hours of the last message of the contact.
- `check_availability`, `schedule_meeting` and `cancel_meeting` let the agent read the free times of the default agenda of the organization, book one and cancel a meeting by its id. They take no `config`, and `meeting.scheduled` and `meeting.canceled` fire as for any other meeting.

```bash title="Let the agent hand a case over"
curl -X PUT https://api.lelemon.cl/v2/agents/$AGENT_ID/tools/transfer_to_human \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "stageId": "stg_5RvP2aKq9XwLmN3tYbZc01",
      "businessHours": { "timezone": "America/Santiago", "startHour": 9, "endHour": 18, "activeSaturday": false, "activeSunday": false },
      "farewell": {
        "duringBusinessHours": "Te paso con una persona del equipo. Te escribe en breve.",
        "outsideBusinessHours": "Te paso con una persona del equipo. Hoy ya cerramos: te escribe mañana desde las 9."
      }
    }
  }'
```


- `stageId` is where the case goes. It wins over the pipeline's handoff stage. For a case in another pipeline it is ignored and the pipeline's own setting applies.
- The farewell the contact reads is written by the tool, not by the model, so it cannot promise what the team will not do. `businessHours` picks which of the two texts is used. Without `config` the tool uses a neutral Spanish text.
- `config` replaces the stored one on every `PUT`. Leave it out to clear it. `DELETE` removes the tool and its configuration.
- If a reply promises to hand the case over and the case is not handed over, Lelemon asks the agent once to do it. If the second reply still does not, Lelemon hands the case over itself and the contact reads the farewell. A message that promises a handoff never goes out on a case that is still with the agent.

```bash title="Let the agent write two fields"
curl -X PUT https://api.lelemon.cl/v2/agents/$AGENT_ID/tools/save_case_data \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "config": { "fields": ["comuna", "rut"] } }'
```


- `fields` lists field keys. Each must be a field with `source: agent` that is not `sensitive`, in one of your pipelines; otherwise the answer is `422 unprocessable`. Leave `fields` out to allow every non-sensitive field with source `agent`.
- At run time the tool still refuses a sensitive field, a field of any other source, a key that does not exist and an empty value, whatever `fields` says.

## Variables of Lelemon

Some fields should never come from the model. A session id the model invents is a session id that does not match. Lelemon fills these fields itself when it makes the call. The agent does not see them and cannot change them. They go in the query when the method is `GET` and in the body otherwise. If Lelemon does not have the value, the field is left out; it never sends an empty or made-up one.

Declare them in `variables` when you create the action (`POST /v2/actions`) or change it (`PATCH /v2/actions/{id}`). Each one has the `name` your endpoint receives and a `source`.

```bash title="Fill a session id and a field of the case"
curl -X POST https://api.lelemon.cl/v2/actions \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "add_to_cart",
    "description": "Adds a product to the cart of the customer.",
    "request": { "method": "POST", "url": "https://api.mesa.cl/lelemon/cart" },
    "input": { "type": "object", "properties": { "sku": { "type": "string" } } },
    "variables": [
      { "name": "session_id", "source": "lelemon", "variable": "case.id" },
      { "name": "tax_id", "source": "case_field", "field": "rut_comprador", "optional": true }
    ]
  }'
```


- `source: "lelemon"` takes a fact Lelemon knows, named by `variable` (the table below).
- `source: "case_field"` takes a field of the case that the pipeline declares, named by `field`. If the case has no value, the action does not run, unless `optional` is `true`: then the field is left out of the request.
- `PATCH` replaces the whole list. Omit `variables` to keep the current one and send `[]` to remove them. The action returns them in `variables`.
- A `name` cannot repeat or match a property of `input`, ignoring case. A variable that does not exist, a name that does not fit, a repeated name or a `case_field` without `field` answers `400 invalid_request` with the `param` that failed, for example `variables.0.variable`. A variable that cannot go in a `GET` action answers [`action_variable_in_query`](/errors/action_variable_in_query), and `contact.phone` or `contact.whatsapp_id` without the authorization of the destination answers [`action_variable_needs_authorization`](/errors/action_variable_needs_authorization).

| Variable | What your endpoint receives | Personal data |
| --- | --- | --- |
| `case.id` | The id of the conversation. It is a new one when the previous one was closed and the person writes again | Yes |
| `case.channel` | The channel it arrived by | No |
| `agent.id` | The agent that is talking | No |
| `organization.id` | Your organization | No |
| `contact.id` | The id of the contact, `ctc_...`. Only in a conversation with the public | Yes |
| `contact.external_ref` | The `externalRef` you set on the contact. Only in a conversation with the public | Yes |
| `subaccount.id` | The subaccount of the turn, `sub_...`. Missing outside a subaccount | No |
| `actor.id`, `actor.role`, `actor.declared_by` | The person you declared when you created the message, their role and the key that declared them | Id: yes |
| `contact.phone` | The number the person writes from, as WhatsApp reports it. Missing if the person uses a username | Yes |
| `contact.whatsapp_id` | The WhatsApp id of the person, which Meta sends even when the person hides their number | Yes |

`contact.phone` and `contact.whatsapp_id` leave only from agents that talk to the public, in `POST`, `PUT`, `PATCH` or `DELETE` actions, and with a current destination `authorization` for that URL. Without it the action is not saved: the answer is `400 action_variable_needs_authorization`, with `param: authorization`. The data they send shows in `authorization.dataSent`.

## What your endpoint receives

Every call of an action carries these headers. A value that does not exist for the turn is left out, never invented.

| Header | Value |
| --- | --- |
| `Lelemon-Case-Id` | `cse_...`, the case of the turn. |
| `Lelemon-Contact-Id` | `ctc_...`, the contact. Only in a conversation with a customer. |
| `Lelemon-Contact-External-Ref` | The `externalRef` you set on the contact. Left out when it is not a plain header value. |
| `Lelemon-Subaccount-Id` | `sub_...`, when the turn belongs to a subaccount. |
| `Lelemon-Actor-Id`, `Lelemon-Actor-Role` | The person you declared when creating the message. |
| `Lelemon-Actor-Declared-By` | `key_...`, the API key that declared the actor. |
| `Lelemon-Tool-Id` | `act_...`, the action being called. |
| `Idempotency-Key` | One per operation: the same turn, the same action and the same arguments (the order of the keys does not matter). If the model retries the call you get the same key: use it to avoid charging or refunding twice. A call with other arguments gets another key. |
| `Lelemon-Signature` | `t=...,jti=...,v1=...`. See below. |

The `Lelemon-` prefix is reserved. A header you configure on an action can never use it, and one that tries is rejected when you save the action (and dropped on the way out for actions saved before).

A fixed `Idempotency-Key` configured on the action is replaced by the one of the call.

Two identical calls in the same turn are one operation; vary an argument (for example quantity) to make two.

Actions that Lelemon provides to every organization receive none of these.

## Verify the signature

`v1` is an HMAC-SHA256, in hexadecimal, of a text that joins: the domain `lelemon-action-v1`, `t`, `jti`, the method, the host, the path with its query, the SHA-256 of the raw body, and the context headers above in a fixed order, the `Idempotency-Key` included. Change any of them and the signature stops matching, so a signature captured from one call is useless for another route, query, body, case, contact, actor, action or organization.

Reject the call when no `v1` matches, when `t` is more than 60 seconds from your clock, or when you have already seen the `jti`.

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

const app = express();
const isReplay = createReplayGuard();

app.post('/carrito', express.raw({ type: '*/*' }), async (req, res) => {
  try {
    const { context } = await verifyRequest(
      req.headers,
      req.method,
      `https://erp.example.com${req.originalUrl}`,
      req.body,
      LELEMON_ACTION_SECRETS, // the list from GET /v2/actions/{id}/signing-secret
      { isReplay, expectedToolId: 'act_...' },
    );
    // context.caseId, context.contactId, context.actor, context.idempotencyKey...
    res.sendStatus(200);
  } catch {
    res.sendStatus(401);
  }
});
```


`verifyRequest` is not `verifyWebhook`: webhooks sign only the body, with another secret and another domain, and a webhook signature never verifies as an action call. Use the raw body and the full public URL, as the caller sees it.

## The secret

Your organization has one secret for actions and every action gets its own, derived from it. Read the one of an action with `GET /v2/actions/{id}/signing-secret` and give it only to the system that serves that action.

`POST /v2/actions/signing-secret/rotate` creates a new one. The previous keeps signing next to the new one for 24 hours (the header carries two `v1`), and the list in `secrets` has both, so you can change your systems without a cut.

## Who is speaking

For an embedded copilot, say who is speaking when you create the message:

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "author": "contact",
    "text": "Refund my last order",
    "actor": { "id": "user_8841", "role": "staff", "name": "Ana Pérez" }
  }'
```


`role` is `end_user`, `staff` or `admin`. Lelemon does not verify the actor: it passes on what your key declared and marks it with `Lelemon-Actor-Declared-By`. A key of a subaccount cannot declare a role above `staff` (403 `actor_role_above_key`). The actor is kept for that turn only and travels to every action and MCP server the agent calls in it, also when the call waits for an approval.

## What an MCP server of yours receives

Turn on the run identity of the server so Lelemon signs its calls. Then every `tools/call` carries a JWT (HS256, 60 seconds) in the header you configured, and the context is in its claims.

```bash title="Turn it on or rotate it"
curl -X POST "https://api.lelemon.cl/v2/mcp-servers/mcp_.../run-identity" \
  -H "Authorization: Bearer $LELEMON_API_KEY" -H "Content-Type: application/json" \
  -d '{ "header": "x-lelemon-run", "audience": "https://mcp.example.com/mcp" }'
```

```ts title="Verify the call"
import { verifyMcpAssertion } from '@lelemon-ai/sdk';

const claims = await verifyMcpAssertion(req.headers['x-lelemon-run'], { [kid]: secret }, {
  audience: 'https://mcp.example.com/mcp',
  expectedTool: call.name,
  expectedArgs: call.arguments,
});
```


The answer carries the `secret` once and its `kid`; the JWT header carries the same `kid`. Calling it again rotates: you get a new secret and the previous one keeps verifying until the next rotation, so keep both by `kid`. `GET /v2/mcp-servers/{id}/run-identity` tells you whether it is on and which `kid` signs, never the secret; `DELETE` turns it off. `?dryRun=true` checks the header and the audience and says whether it would rotate, without saving anything. The header cannot be one the server already uses for its own auth.

| Claim | Value |
| --- | --- |
| `sub` | The actor, or the contact when there is no actor. In a run created by API, the verified user of the run. |
| `case`, `contact`, `xref`, `subaccount` | The same ids and reference as the action headers. |
| `actor` | `{ "id", "role", "by" }`. |
| `org`, `srv`, `tool`, `args`, `idem`, `jti` | The organization (`org_…`) and the server (`mcp_…`) as public ids, the tool, the hash of the arguments, the idempotency key and a unique id. |

The same values travel in `_meta` of the request under the keys `cl.lelemon/case-id`, `cl.lelemon/contact-id`, `cl.lelemon/contact-external-ref`, `cl.lelemon/subaccount-id`, `cl.lelemon/actor-id`, `cl.lelemon/actor-role` and `cl.lelemon/actor-declared-by`. `_meta` is informational: trust the JWT. Without a signature there is no context, so `_meta` never arrives alone.

## Operate Lelemon from an MCP client

A coding agent or a copilot can operate the Lelemon API through the MCP server `@lelemon-ai/mcp`, generated from the contract. Install, variables, filters, hidden groups and annotations are on [MCP server](/tools/mcp).

## Try before you do

Every route that sends a message, costs money or changes a configuration that is hard to undo accepts `?dryRun=true`: sending a template, an email or a case message, reporting a contact event, testing a template, rotating a key or a secret, creating or updating campaigns, actions and triggers, replacing the recipient allowlist, erasing a contact, and launching or resuming a campaign. The call runs the same checks as the real one and answers `200` with what would happen, with `dryRun: true` in the body, and nothing is saved or sent. An `id` in that answer is not saved, and `apiKeys.rotate` does not show a real secret, and `agents.activate` answers with the agent as it would be without turning it on. It does not use the `Idempotency-Key`, so the real call can reuse it. A route that does not accept it answers `400 invalid_request` with `param: dryRun` instead of doing the work.

```bash title="See the list before replacing it"
curl -X PUT "https://api.lelemon.cl/v2/recipient-allowlist?dryRun=true" \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true, "entries": ["+56912345678", "@mesa.cl"] }'
```


## Let the error tell you what to do

An error from a code with a known remedy carries a `fix`. It always comes from the error catalog and never repeats what you sent.

```json title="409 response"
{
  "code": "idempotency_key_reused",
  "status": 409,
  "fix": {
    "summary": "Send a new Idempotency-Key for this request. Reuse a key only to retry the exact same request.",
    "action": "set",
    "param": "Idempotency-Key",
    "docUrl": "https://docs.lelemon.cl/errors/idempotency_key_reused"
  }
}
```


`action` is one of `set` or `remove` a parameter, `retry` the same request, `call` another endpoint first (named in `endpoint` as `METHOD /v2/path`), or `stop` because only a person can fix it.
