Actions and MCP in v2
There are two different things called MCP here. Read the one you need.
- Your tools for an agent. You register an action (an HTTP endpoint of yours) or an MCP server 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 through a server generated from the contract.
- The contact writes. A case opens or continues and the agent starts a turn.
- 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.
- 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.
- Your backend verifies the signature with verifyRequest, does the work and answers with the result the agent reads.
- 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.
- Your MCP server answers. Trust the JWT, not _meta, which is informational.
- The agent writes the reply to the contact on the channel where they wrote.
- 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.
An action is this object. Open it to see its fields:
Actionobject, 13 fields
objectenumrequiredactionidstringrequirednamestringrequireddescriptionstringrequiredrequestActionRequestrequiredActionRequest4 fields
methodenumrequiredGETPOSTPUTPATCHDELETEoriginstringrequiredOnly the origin. The path and query may hold a secret.
headerNamesstring[]requiredNames only, never the values.
timeoutMsintegeroptional
inputobjectrequiredaudienceenumrequiredallcustomersinternalnoPersistbooleanrequiredWhen 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.
sharedWithSubaccountsbooleanrequiredPlatforms 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.
variablesActionVariable[]requiredThe fields Lelemon fills on every call. Empty when there are none.
ActionVariable5 fields
namestringrequiredsourceenumrequiredlelemoncase_fieldvariablestringoptionalWith source lelemon. A key the catalog no longer has is ignored when the action runs.
fieldstringoptionaloptionalbooleanoptionalWith case_field.
authorizationActionAuthorizationRecord or nullrequiredThe 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.
ActionAuthorizationRecord6 fields
purposestringrequiredWhat the data sent to your system is used for, as it was accepted.
destinationCountryenumrequiredchileoutside_chileunknownversionstringrequiredThe version of the authorization text that was accepted.
acceptedAtstring (date-time)requiredconfirmedTransferOutsideChilebooleanrequiredTrue when the transfer outside Chile was confirmed apart. False when the system is in Chile.
dataSentstring[]requiredThe names of the input fields that leave for your system, never their values.
createdAtstring (date-time)requiredupdatedAtstring (date-time)required
- The URL must be public
httporhttps. Its site must be allowed for the API key; an administrator adds sites to the key. Otherwise the answer is403 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 with400onrequest.url: what changes per call goes ininput(the query forGET, the body otherwise). - The optional
authorizationblock (purpose,destinationCountry,version, andconfirmsTransferOutsideChileoutside Chile) records the authorization of article 15 bis of Law 21.719. Read the text and its version withGET /v2/actions/authorization. Send all of the block or none of it;PATCHaccepts it too. - The action does nothing until you grant it to an agent with
PUT /v2/agents/{id}/tools/{toolId}. Useaudience: customersfor an action that agents talking to the public may use. POST /v2/actions/{id}/testcalls your endpoint once with sample input. It really calls it: use sample data when the action writes. The call carriesLelemon-Signature, the context headers and anIdempotency-Keylike 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.
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.
- In GitHub, create a fine-grained token that can only read the repositories you want to share, with read access to Contents and Metadata.
- In Integrations > MCP, press Conectar tu sistema. Use the name
GitHub, the addresshttps://api.githubcopilot.com/mcp/readonly, authentication Con un token, and paste the token. - 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_humanhands the case to a person. The case moves to human control, goes to the handoff stage and firescase.escalated.save_case_datalets the agent write what the contact says into the case fields whosesourceisagent.send_buttons,send_list,send_products,send_carousel,send_cta_url,send_flowandrequest_locationlet 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 noconfig. 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_meetingandcancel_meetinglet the agent read the free times of the default agenda of the organization, book one and cancel a meeting by its id. They take noconfig, andmeeting.scheduledandmeeting.canceledfire as for any other meeting.
stageIdis 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.
businessHourspicks which of the two texts is used. Withoutconfigthe tool uses a neutral Spanish text. configreplaces the stored one on everyPUT. Leave it out to clear it.DELETEremoves 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.
fieldslists field keys. Each must be a field withsource: agentthat is notsensitive, in one of your pipelines; otherwise the answer is422 unprocessable. Leavefieldsout to allow every non-sensitive field with sourceagent.- 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
fieldssays.
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.
source: "lelemon"takes a fact Lelemon knows, named byvariable(the table below).source: "case_field"takes a field of the case that the pipeline declares, named byfield. If the case has no value, the action does not run, unlessoptionalistrue: then the field is left out of the request.PATCHreplaces the whole list. Omitvariablesto keep the current one and send[]to remove them. The action returns them invariables.- A
namecannot repeat or match a property ofinput, ignoring case. A variable that does not exist, a name that does not fit, a repeated name or acase_fieldwithoutfieldanswers400 invalid_requestwith theparamthat failed, for examplevariables.0.variable. A variable that cannot go in aGETaction answersaction_variable_in_query, andcontact.phoneorcontact.whatsapp_idwithout the authorization of the destination answersaction_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.
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:
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.
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.
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.
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.
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.