# Support with tools and human approval

**You will:** Give a support agent two actions of your own and put the money-moving one behind a person, with a threshold, a cumulative cap and a hard limit.

**You need:**
- An API key with agents, pipelines, actions, approvals, webhooks and cases scopes
- An https backend that serves GET /orders and POST /refunds
- Node.js 20 or later

A customer writes that part of an order arrived damaged. The agent looks the order up, checks the item and the amount, and calls `issue_refund`. Small refunds run at once. When the call crosses your threshold, or the sum of the month would pass the cap, the action does not run: it becomes an approval that waits for a person. You approve it by API (or in the dashboard) and only then the action runs and the agent tells the customer.

**Who it is for.** Stores and services that refund or credit money and want a person to decide the big ones. **Scopes.** `agents:write`, `pipelines:write`, `actions:write`, `approvals:read`, `approvals:write`, `webhooks:write`, `cases:write`, `cases:converse`, `contacts:write`.

```text title="The flow"
Customer ---------> Agent --get_order----------------------> your backend (signed GET)
   "refund $12.000"   |--issue_refund($12.000)  under the rules ---> runs, agent gives the refund id
   "refund $18.000"   |--issue_refund($18.000)  month total $30.000 > cap $25.000
                      |         \-> approval.requested (webhook) ---> person: POST /v2/approvals/{id}/approve
                      |                                              the action runs now
                      '<-------- result: the agent writes to the customer
```

The runnable script is in [`examples/use-cases/support-with-approvals`](https://github.com/Lelemon-studio/lelemon-app/tree/main/apps/docs/examples/use-cases/support-with-approvals).

## 0. Set up

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

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

## 1. Register the two actions

`get_order` reads and `issue_refund` writes. Both take the customer from Lelemon, not from the model: `variables` with `contact.external_ref` makes Lelemon send the `customerRef` itself, so the agent cannot ask for the order of another person. Your backend checks that the order belongs to that customer.

```bash title="cURL"
curl https://api.lelemon.cl/v2/actions \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: action-refund-1" \
  --json '{
    "name": "issue_refund",
    "description": "Refund part or all of an order to the original payment method. The amount is in Chilean pesos. Use it only after you checked the order and the customer confirmed the amount.",
    "audience": "customers",
    "sharedWithSubaccounts": true,
    "request": { "method": "POST", "url": "https://orders.example.com/refunds" },
    "input": {
      "type": "object",
      "required": ["orderNumber", "amount", "reason"],
      "properties": {
        "orderNumber": { "type": "string" },
        "amount": { "type": "number", "description": "Amount to refund in CLP, a whole number." },
        "reason": { "type": "string", "description": "One sentence on why, in English." }
      }
    },
    "variables": [{ "name": "customerRef", "source": "lelemon", "variable": "contact.external_ref" }]
  }'
```

```ts title="Node.js"
const getOrder = await lelemon.actions.create({
  name: 'get_order',
  description: 'Look up an order of the customer you are talking to by its number: status, delivery date, total and items.',
  audience: 'customers',
  request: { method: 'GET', url: 'https://orders.example.com/orders' },
  input: {
    type: 'object',
    required: ['orderNumber'],
    properties: { orderNumber: { type: 'string', description: 'The order number, such as BN-2031.' } },
  },
  variables: [{ name: 'customerRef', source: 'lelemon', variable: 'contact.external_ref' }],
});
const issueRefund = await lelemon.actions.create({
  name: 'issue_refund',
  description: 'Refund part or all of an order to the original payment method. The amount is in Chilean pesos. Use it only after you checked the order and the customer confirmed the amount.',
  audience: 'customers',
  sharedWithSubaccounts: true,
  request: { method: 'POST', url: 'https://orders.example.com/refunds' },
  input: {
    type: 'object',
    required: ['orderNumber', 'amount', 'reason'],
    properties: {
      orderNumber: { type: 'string' },
      amount: { type: 'number', description: 'Amount to refund in CLP, a whole number.' },
      reason: { type: 'string', description: 'One sentence on why, in English.' },
    },
  },
  variables: [{ name: 'customerRef', source: 'lelemon', variable: 'contact.external_ref' }],
});
for (const action of [getOrder, issueRefund]) {
  const { secrets } = await lelemon.actions.retrieveSigningSecret(action.id);
}
```


Your server verifies the signature of each call ([how](/guides/actions-and-mcp#verify-the-signature)) and answers JSON. `sharedWithSubaccounts` is only needed if you want a [floor](#4-set-a-floor-that-no-subaccount-can-lower).

## 2. Write the agent and the pipeline

The prompt carries the policy that approvals cannot: what a refund is for, what must be confirmed, what to say for each of the three answers of `issue_refund`, and when to hand over. It also says that a clear request is the confirmation, so the agent does not ask twice.

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

```ts title="Node.js"
const instructions = `You are Pedro, the support assistant of Bicicletas Norte, an online bike parts store in Chile. You answer customers on chat about their orders.

## Goal
Resolve the problem in this conversation when you can: tell the customer where their order is, explain what happened and, when a part arrived damaged or wrong, start a refund.

## Voice
- Chilean Spanish, "tú", calm and specific. Two or three sentences per message. No emojis.
- Say what you did and what happens next. Never say "I will check" without checking.

## Orders
- Always call get_order before you talk about an order. If it says order_not_found, ask the customer to read the number again once; if it is still not found, use transfer_to_human.
- Never share data of an order you did not look up in this conversation.

## Refunds
- A refund is for a part that arrived damaged, wrong or not at all. For anything else (the customer changed their mind, the part does not fit), explain the return policy: 10 days, unused, the customer pays the shipping back. Do not refund.
- issue_refund needs three things: which item, why, and the exact amount. When the customer's message already names the item, says what is wrong and states the amount, that is the confirmation: call issue_refund in that same turn and do not ask again. Ask only for what is missing. The amount can never be more than the price of that item on the order.
- issue_refund can answer in three ways. If it says the refund was issued, give the customer the refundId and say it takes 3 to 5 business days to reach the card. If it says the request is waiting for approval, tell the customer a person is reviewing it, that they will get an answer in this chat within four hours, and do not call it again. If it was rejected, repeat the reason that came with it and offer a person with transfer_to_human.
- Never promise a refund before the tool confirms it.

## Limits
- You do not change addresses, cancel shipped orders or give discounts.
- Do not ask for card numbers.
- If the customer is angry, mentions a lawyer or a consumer complaint (SERNAC), or asks for a person, use transfer_to_human immediately.`;
const agent = await lelemon.agents.create({
  name: 'Pedro',
  description: 'Support of Bicicletas Norte',
  instructions,
  llmConfig: { model: 'anthropic/claude-sonnet-5.5', temperature: 0.2 },
  channels: ['api'],
  behaviors: { displayName: 'Pedro' },
});

const pipeline = await lelemon.pipelines.create({
  name: 'Support',
  stages: [
    { name: 'Open', entry: true, agentId: agent.id },
    { name: 'Needs a person' },
    { name: 'Resolved' },
  ],
});
const stages = await lelemon.pipelines.stages.list(pipeline.id);
const stageId = (name: string) => stages.data.find((s) => s.name === name)!.id;
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'order_number',
  type: 'text',
  label: 'Order number',
  source: 'agent',
  instruction: 'The number of the order the customer is asking about, such as BN-2031.',
});
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'issue_type',
  type: 'select',
  label: 'Issue',
  source: 'agent',
  options: [
    { value: 'where_is_my_order', label: 'Where is my order' },
    { value: 'damaged', label: 'Arrived damaged' },
    { value: 'wrong_item', label: 'Wrong item' },
    { value: 'other', label: 'Other' },
  ],
});
```


## 3. Put refunds behind a person

`PUT /v2/agents/{id}/tools/{toolId}` grants the action with an `approval` policy. `mode: "rules"` takes an ordered list and the first rule that applies decides:

- above $150.000 the call is rejected and the agent says so (`reject`);
- above $20.000 a person decides (`require_approval`);
- anything else runs.

`accumulated` adds a rolling cap per contact and action: it stops a split into small refunds, because each one is small and the sum is not. Here the month cannot pass $25.000 without a person. `approvers` names who may decide (here, the API key of your back office); `expiresInMinutes` is when the request dies and `escalateAfterMinutes` fires `approval.escalated` once.

```bash title="cURL"
curl -X PUT https://api.lelemon.cl/v2/agents/agt_.../tools/act_... \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "approval": {
      "mode": "rules",
      "rules": [
        { "when": { "param": "amount", "op": "gt", "value": 150000 }, "effect": "reject",
          "customerMessage": "A refund this large needs a manager. I am handing you to a person." },
        { "when": { "param": "amount", "op": "gt", "value": 20000 }, "effect": "require_approval",
          "approvers": [{ "type": "key", "value": "key_..." }] }
      ],
      "accumulated": { "param": "amount", "period": "month", "limit": 25000 },
      "approvers": [{ "type": "key", "value": "key_..." }],
      "expiresInMinutes": 240,
      "escalateAfterMinutes": 60
    }
  }'
```

```ts title="Node.js"
const me = await lelemon.me.retrieve();
const approvers = [{ type: 'key' as const, value: me.apiKey.id }];
await lelemon.agents.tools.grant(agent.id, getOrder.id);
const refundTool = await lelemon.agents.tools.grant(agent.id, issueRefund.id, {
  approval: {
    mode: 'rules',
    rules: [
      {
        when: { param: 'amount', op: 'gt', value: 150000 },
        effect: 'reject',
        customerMessage: 'A refund this large needs a manager. I am handing you to a person.',
      },
      { when: { param: 'amount', op: 'gt', value: 20000 }, effect: 'require_approval', approvers },
    ],
    accumulated: { param: 'amount', period: 'month', limit: 25000 },
    approvers,
    expiresInMinutes: 240,
    escalateAfterMinutes: 60,
  },
});
await lelemon.agents.tools.grant(agent.id, 'save_case_data', { config: { fields: ['order_number', 'issue_type'] } });
await lelemon.agents.tools.grant(agent.id, 'transfer_to_human', { config: { stageId: stageId('Needs a person') } });
const activated = await lelemon.agents.activate(agent.id);
```


```text
refund policy {"mode":"rules","rules":[{"when":{"param":"amount","op":"gt","value":150000},"effect":"reject","customerMessage":"A refund this large needs a manager. I am handing you to a person."},{"when":{"param":"amount","op":"gt","value":20000},"effect":"require_approval","approvers":[{"type":"key","value":"key_1LCjyzH1d3yu8glA7Yy9b1"}]}],"accumulated":{"param":"amount","period":"month","limit":25000},"approvers":[{"type":"key","value":"key_1LCjyzH1d3yu8glA7Yy9b1"}],"expiresInMinutes":240,"escalateAfterMinutes":60,"automatedDecision":false}
    readiness {"ready":true,"missing":[]}
```

`get_order` is granted with no policy, so it runs freely. The other tools: `save_case_data` for the two fields and `transfer_to_human` for the stage `Needs a person`. See [Approve agent actions](/guides/approvals) for every rule.

## 4. Set a floor that no subaccount can lower

If you run Lelemon for other companies ([platform](/use-cases/platform-subaccounts)), an action you share with your subaccounts can carry a floor: the least approval any subaccount can run it with. A subaccount can be stricter, never looser, and cannot grant the action with `mode: "never"` below the floor. Here, any refund above $100.000 waits for an owner, whatever the policy of the subaccount says.

```bash title="cURL"
curl -X PUT https://api.lelemon.cl/v2/actions/act_.../approval-floor \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "mode": "when",
    "condition": { "param": "amount", "op": "gt", "value": 100000 },
    "approvers": [{ "type": "role", "value": "owner" }]
  }'
```

```ts title="Node.js"
const floor = await lelemon.actions.approvalFloor.set(issueRefund.id, {
  mode: 'when',
  condition: { param: 'amount', op: 'gt', value: 100000 },
  approvers: [{ type: 'role', value: 'owner' }],
});
const tools = await lelemon.agents.tools.list(activated.id, { query: { granted: 'true' } });
```


```text
floor {"mode":"when","condition":{"param":"amount","op":"gt","value":100000},"approvers":[{"type":"role","value":"owner"}],"expiresInMinutes":1440,"automatedDecision":false}
    refund tool [{"approval":"rules","floor":"when"}]
```

`GET /v2/agents/{id}/tools` shows both on the tool: `approval` (the policy of the agent) and `approvalFloor`.

## 5. Listen for approvals

```bash title="cURL"
curl https://api.lelemon.cl/v2/webhook-endpoints \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: webhook-approvals-1" \
  --json '{ "url": "https://example.com/lelemon", "enabledEvents": ["approval.requested", "approval.decided", "approval.escalated"] }'
```

```ts title="Node.js"
const endpoint = await lelemon.webhookEndpoints.create({
  url: 'https://example.com/lelemon',
  enabledEvents: ['approval.requested', 'approval.decided', 'approval.escalated', 'case.turn_completed'],
});
```


## 6. Try it: a refund that runs, and one that waits

The first message names the item, the problem and the amount, so it is also the confirmation. $12.000 is under the threshold and under the cap, so `issue_refund` runs and the agent gives the refund id.

```bash title="cURL"
curl https://api.lelemon.cl/v2/contacts \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: contact-diego-1" \
  --json '{ "name": "Diego Fuentes", "phone": "+56912345678", "externalRef": "cust-5001" }'

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

curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: msg-refund-1" \
  --json '{ "author": "contact", "text": "Hola, mi pedido BN-2031 llegó con la cinta de corcho cortada. Quiero que me devuelvan los $12.000 de la cinta, procesa el reembolso por favor." }'
```

```ts title="Node.js"
const contact = await lelemon.contacts.create({ name: 'Diego Fuentes', phone: '+56912345678', externalRef: 'cust-5001' });
const kase = await lelemon.cases.create({ contactId: contact.id, agentId: agent.id });
await lelemon.caseMessages.create(kase.id, {
  author: 'contact',
  text: 'Hola, mi pedido BN-2031 llegó con la cinta de corcho cortada. Quiero que me devuelvan los $12.000 de la cinta, procesa el reembolso por favor.',
});
```


```text
customer Hola, mi pedido BN-2031 llegó con la cinta de corcho cortada. Quiero que me devuelvan los $12.000 de la cinta, procesa el reembolso por favor.
    agent Encontré tu pedido BN-2031, entregado el 28 de octubre. Ya procesé el reembolso de $12.000 por la cinta de corcho dañada, con el número RF-1. Tarda entre 3 y 5 días hábiles en llegar a tu tarjeta.
    refunds issued [{"id":"RF-1","orderNumber":"BN-2031","amountClp":12000,"reason":"Bar tape cork (CLP 12,000) arrived cut/damaged."}]
```

The second item is also under $20.000, but the month would reach $30.000 and the cap is $25.000. The call does not run:

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: msg-refund-2" \
  --json '{ "author": "contact", "text": "Y la llanta también vino con un corte. Quiero que me devuelvan los $18.000 de la llanta, procesa el reembolso por favor." }'

curl "https://api.lelemon.cl/v2/approvals?status=pending" \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
await lelemon.caseMessages.create(kase.id, {
  author: 'contact',
  text: 'Y la llanta también vino con un corte. Quiero que me devuelvan los $18.000 de la llanta, procesa el reembolso por favor.',
});
const pending = await lelemon.approvals.list({ query: { status: 'pending' } });
const approval = pending.data[0]!;
```


```text
customer Y la llanta también vino con un corte. Quiero que me devuelvan los $18.000 de la llanta, procesa el reembolso por favor.
    agent Tu reembolso de $18.000 por la llanta quedó en revisión, así que todavía no está aprobado. Una persona del equipo lo está revisando y te responderemos en este chat dentro de las próximas cuatro horas.

El reembolso de $12.000 por la cinta de corcho ya está procesado con el número RF-1.
    webhook {"type":"approval.requested"}
    approval {"id":"apr_5vQHKqxkYuaZ0UccsRSbND","status":"pending","tool":"issue_refund","arguments":{"amount":18000,"reason":"Tire 700x35 gravel (CLP 18,000) arrived with a cut.","orderNumber":"BN-2031"},"reasonCode":"policy_accumulated","summary":"issue_refund (orderNumber: \"BN-2031\", amount: 18000, reason: \"Tire 700x35 gravel (CLP 18,000) arrived with a cut.\")","approvers":[{"type":"key","value":"key_1LCjyzH1d3yu8glA7Yy9b1"}],"expiresAt":"2026-11-01T07:33:38.208Z"}
```

`reasonCode` says why: `policy_accumulated` here, `policy_rule` for the threshold, `policy_always` or `policy_condition` for the other modes. The agent told the customer that a person is reviewing it and did not call the action again.

## 7. A person approves

`approve` runs the action now with the arguments you read, then the agent resumes the conversation with the result. It has the real effect of the action, so only the approvers of the policy can call it. To say no, `POST /v2/approvals/{id}/reject` with a `reason`: the action never runs and the agent tells the customer.

```bash title="cURL"
curl https://api.lelemon.cl/v2/approvals/apr_.../approve \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: approve-1" \
  --json '{ "reason": "Photo of the damage checked" }'

curl https://api.lelemon.cl/v2/approvals/apr_.../reject \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: reject-1" \
  --json '{ "reason": "The photo shows normal wear" }'
```

```ts title="Node.js"
const approved = await lelemon.approvals.approve(approval.id, { reason: 'Photo of the damage checked' });
```


```text
approval {"id":"apr_5vQHKqxkYuaZ0UccsRSbND","status":"executed","decision":{"decidedAt":"2026-11-01T03:33:41.225Z","by":{"type":"key","id":"key_1LCjyzH1d3yu8glA7Yy9b1"},"reason":"Photo of the damaged tape checked"},"result":"{\"refundId\":\"RF-2\",\"amountClp\":18000,\"status\":\"issued\"}"}
    agent Tu reembolso de $18.000 por la llanta 700x35 que llegó cortada ya fue emitido. El número de reembolso es **RF-2**. Debería llegar a tu tarjeta en 3 a 5 días hábiles.
    refunds issued [{"id":"RF-1","orderNumber":"BN-2031","amountClp":12000,"reason":"Bar tape cork (CLP 12,000) arrived cut/damaged."},{"id":"RF-2","orderNumber":"BN-2031","amountClp":18000,"reason":"Tire 700x35 gravel (CLP 18,000) arrived with a cut."}]
```

`status: "executed"` and the `result` of your backend. Reading the approval at `GET /v2/approvals/{id}` shows the whole story: who decided, when and with which reason.

## Notes

- Your backend receives real signed calls, so test the signature code too.
- `POST /v2/actions/{id}/test` calls your endpoint once with sample input, without a case.

## What you see in the dashboard

- **Approvals:** the pending request with the action, its arguments, the reason and who may decide. Deciding there is the same as the API.
- **Inbox:** the thread, with the agent's message that a person is reviewing it, and the later one with the refund id.
- **Agent, Tools:** `issue_refund` with its policy and, for a shared action, the floor.

## What's next

- [Approve agent actions](/guides/approvals): rules, approvers, roles and automated decisions.
- [Run Lelemon for your customers](/use-cases/platform-subaccounts): share the action and fix the floor.
- [Track usage and cap conversations](/guides/usage-and-budgets).
