# Give an agent a background job

**You will:** Run a reconciliation job on its own, follow it by webhook, answer the question the agent asks and cancel another run.

**You need:**
- An API key with agents:write, pipelines:write, actions:write, runs:write, runs:read and webhooks:write
- An https accounting backend with three endpoints
- Node.js 20 or later

Some work is longer than a conversation: matching a bank statement with invoices, filling a batch, preparing a report. A run is that job. You give the agent a goal and limits and the answer comes back at once with the run `running`. The agent works outside your request, for as many rounds as it needs. If it hits a case it cannot decide, it pauses and asks; you answer and it continues. When it is done it declares a summary and the things to check. You hear about every change by webhook and can cancel at any time.

**Who it is for.** Back offices and operations teams that delegate unattended work. **Scopes.** Listed above. `runs:write` decides which person the agent acts as, so give it only to systems you already run.

```text title="The flow"
Your system                          Agent (outside your request)               Your backend
  | POST /v2/agents/{id}/runs  ->     round 1: list movements, list invoices --->  signed reads
  |   goal, actor, limits             round 2: record_match (clear matches)  --->  signed write
  | <- 201 run "running"              round 3: ambiguity -> pauses
  | <- webhook run.waiting_for_answer
  | POST /v2/runs/{id}/answer  ->     resumes with your answer
  |                                   round 5: finish(summary, checks)
  | <- webhook run.completed          status completed_unverified: read the summary and checks
  | POST /v2/runs/{id}/cancel  ->     a second job ends "canceled"
```

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

## 0. Set up

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

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

## 1. Give the agent the tools of the job

An unattended agent uses any action it is granted, including the ones that write, with nobody looking. Grant only what the job needs: two reads and one write. Actions behind an [approval policy](/guides/approvals) do not run inside a run either: they wait for a person.

```bash title="cURL"
curl https://api.lelemon.cl/v2/actions \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: action-match-1" \
  --json '{
    "name": "record_match",
    "description": "Match one bank movement with one invoice. Only call it when you are sure; a wrong match changes the books.",
    "audience": "all",
    "request": { "method": "POST", "url": "https://books.example.com/matches" },
    "input": {
      "type": "object",
      "required": ["movementId", "invoiceId"],
      "properties": { "movementId": { "type": "string" }, "invoiceId": { "type": "string" } }
    }
  }'
```

```ts title="Node.js"
const definitions = [
  {
    name: 'list_bank_movements',
    description: 'List the bank movements of October that are not matched yet.',
    request: { method: 'GET' as const, url: 'https://books.example.com/movements' },
    input: { type: 'object', properties: {} },
  },
  {
    name: 'list_open_invoices',
    description: 'List the issued invoices that have no payment matched yet.',
    request: { method: 'GET' as const, url: 'https://books.example.com/invoices' },
    input: { type: 'object', properties: {} },
  },
  {
    name: 'record_match',
    description: 'Match one bank movement with one invoice. Only call it when you are sure; a wrong match changes the books.',
    request: { method: 'POST' as const, url: 'https://books.example.com/matches' },
    input: {
      type: 'object',
      required: ['movementId', 'invoiceId'],
      properties: { movementId: { type: 'string' }, invoiceId: { type: 'string' } },
    },
  },
];
const actions = [];
for (const definition of definitions) {
  const action = await lelemon.actions.create({ ...definition, audience: 'all' });
  actions.push(action);
}
```


```text
actions ["list_bank_movements act_2IZsCjqianNzh86dA1W5M6","list_open_invoices act_5JKa1UZzrQaPMIuyqCML5i","record_match act_00CQSZFvd6SIgH1TfAxOll"]
```

## 2. Write the agent

The prompt of a worker is a procedure: what to read first, when two things match, what to do when they are ambiguous (ask, never guess), what never to do and how to finish. The agent must be active, created through the API and have the `api` channel.

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

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

```ts title="Node.js"
const instructions = `You are Lucía, a bookkeeping assistant that reconciles the bank statement of a small business with its issued invoices. You work alone on jobs that you receive as a goal, and a person reads your result later.

## How you work
1. List the bank movements and the open invoices before you decide anything.
2. A movement and an invoice match when the amount is exactly the same and the description names the customer of the invoice. Record each clear match with record_match.
3. When one movement could belong to more than one invoice (same customer and same amount), do not pick one. Ask the person which invoice it is, with the movement id and the invoice ids, in one question. Wait for the answer, then record it.
4. Movements without an invoice (bank fees, for example) are not matches. Never invent an invoice for them.
5. Never match by guessing, never change amounts and never record the same movement twice.

## How you finish
When every movement is matched or explained, finish with a summary that lists what you matched, what you left out and why. In the checks, list the matches the person should look at again, if any.`;
const agent = await lelemon.agents.create({
  name: 'Lucía',
  description: 'Reconciles bank statements in the background',
  instructions,
  llmConfig: { model: 'anthropic/claude-sonnet-5.5', temperature: 0 },
  channels: ['api'],
});
const pipeline = await lelemon.pipelines.create({
  name: 'Bookkeeping',
  stages: [{ name: 'Jobs', entry: true, agentId: agent.id }],
});
for (const action of actions) await lelemon.agents.tools.grant(agent.id, action.id);
const activated = await lelemon.agents.activate(agent.id);
```


```text
agent {"id":"agt_5A9zNFWs5LUPCAgj2G6Fbh","status":"active","ready":true}
```

## 3. Follow runs by webhook

Do not poll. Subscribe to the run events: `run.started`, `run.waiting_for_answer`, `run.completed`, `run.stopped`, `run.failed` and `run.canceled`. Each carries the run in `data.run`, exactly as `GET /v2/runs/{id}` would answer at that moment. They are sent at least once.

```bash title="cURL"
curl https://api.lelemon.cl/v2/webhook-endpoints \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: webhook-runs-1" \
  --json '{ "url": "https://example.com/lelemon", "enabledEvents": ["run.started", "run.waiting_for_answer", "run.completed", "run.stopped", "run.failed", "run.canceled"] }'
```

```ts title="Node.js"
const endpoint = await lelemon.webhookEndpoints.create({
  url: 'https://example.com/lelemon',
  enabledEvents: ['run.started', 'run.waiting_for_answer', 'run.completed', 'run.stopped', 'run.failed', 'run.canceled'],
});
```


## 4. Give the agent a job

`goal` is what must be achieved, read by the agent as text from a user. `actor` is the person in your system the agent works for: your key vouches for them, Lelemon records that your key declared them and the run carries them to every action as the signed actor. `limits` can only lower the defaults (30 rounds, US$ 2, 30 minutes) up to the server maximums.

```bash title="cURL"
curl https://api.lelemon.cl/v2/agents/agt_.../runs \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: reconcile-2026-10" \
  --json '{
    "goal": "Reconcile the October bank statement with the open invoices. Record the matches you are sure about, ask me about the ones you are not, and finish with a summary.",
    "actor": { "id": "user_8841", "role": "staff", "name": "Matías Rivas" },
    "limits": { "maxRounds": 12, "maxMinutes": 10 }
  }'

curl "https://api.lelemon.cl/v2/agents/agt_.../runs?dryRun=true" \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "goal": "Reconcile the October bank statement." }'
```

```ts title="Node.js"
const run = await lelemon.agents.runs.create(agent.id, {
  goal: 'Reconcile the October bank statement with the open invoices. Record the matches you are sure about, ask me about the ones you are not, and finish with a summary.',
  actor: { id: 'user_8841', role: 'staff', name: 'Matías Rivas' },
  limits: { maxRounds: 12, maxMinutes: 10 },
});
```


```text
run {"id":"run_1qcjwUbMC3T7JWVfYFPApU","status":"running","limits":{"maxRounds":12,"maxMinutes":10},"actor":{"id":"user_8841","role":"staff","name":"Matías Rivas","declaredBy":"key_1LCjyzH1d3yu8glA7Yy9b1"}}
```

The same `Idempotency-Key` returns the run already created, not a second one. `?dryRun=true` runs the checks and queues nothing.

## 5. The agent asks, you answer

Two movements of the same customer with the same amount could belong to two invoices. The agent does not guess: the run pauses with `status: "waiting_for_answer"`, `question` has the text and `run.waiting_for_answer` fires. The clear match was already recorded, signed with the actor you declared.

```bash title="cURL"
curl https://api.lelemon.cl/v2/runs/run_... \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl https://api.lelemon.cl/v2/runs/run_.../answer \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: answer-1" \
  --json '{ "answer": "The Café Central transfer pays F-1207. F-1209 is still pending.", "actor": { "id": "user_9020", "role": "staff", "name": "Camila Soto" } }'
```

```ts title="Node.js"
const waiting = await lelemon.runs.retrieve(run.id);
const resumed = await lelemon.runs.answer(run.id, {
  answer: 'The Café Central transfer pays F-1207. F-1209 is still pending.',
  actor: { id: 'user_9020', role: 'staff', name: 'Camila Soto' },
});
```


```text
webhook {"type":"run.waiting_for_answer"}
    run {"status":"waiting_for_answer","rounds":3,"question":"El movimiento mov_2 (transferencia de Café Central, 150.000 CLP, 2026-10-07) podría corresponder a la factura F-1207 o a la F-1209, ambas de Café Central por 150.000 CLP. ¿A cuál de las dos corresponde?"}
    matches so far [{"movementId":"mov_1","invoiceId":"F-1201","by":"user_8841"}]
```

A question is answered once: a run that is not paused answers `409 run_not_waiting`. Someone from your team can also answer in the dashboard, under the agent, in **Activity**.

## 6. Read the result

The agent ends a run by calling its finish action with a summary and a list of things to check. The status is `completed_unverified`: nobody has verified it, so read `result.summary` and `result.checks` before you trust it.

```bash title="cURL"
curl https://api.lelemon.cl/v2/runs/run_... \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const done = await lelemon.runs.retrieve(run.id);
```


```text
webhook {"type":"run.completed"}
    run {"status":"completed_unverified","rounds":5,"result":{"summary":"Conciliación de octubre. Matches registrados: (1) mov_1, transferencia Panadería Doña Rosa 238.000 CLP (2026-10-05) con factura F-1201 (mismo cliente, mismo monto). (2) mov_2, transferencia Café Central 150.000 CLP (2026-10-07) con factura F-1207, según confirmación de la persona que encargó la tarea. Quedó sin conciliar: factura F-1209 (Café Central, 150.000 CLP), pendiente de pago según la respuesta recibida. Excluido: mov_3, cargo bancario \"Bank fee\" por -4.500 CLP (2026-10-12), que no corresponde a ninguna factura.","checks":["mov_1 (238.000 CLP) registrado con F-1201 (Panadería Doña Rosa).","mov_2 (150.000 CLP) registrado con F-1207 (Café Central), decisión confirmada por la persona encargada.","F-1209 (Café Central, 150.000 CLP) sigue abierta, sin movimiento bancario asociado.","mov_3 (Bank fee, -4.500 CLP) no conciliado a propósito: es un cargo bancario sin factura."]}}
    matches [{"movementId":"mov_1","invoiceId":"F-1201","by":"user_8841"},{"movementId":"mov_2","invoiceId":"F-1207","by":"user_8841"}]
```

If a run ends `stopped` (`rounds`, `cost`, `time`, `no_conclusion` or `loop`) or `failed`, `result.partial` keeps the last thing the agent wrote: read it before you start the job again. See [how a run ends](/guides/runs#how-a-run-ends).

## 7. Cancel a job

`POST /v2/runs/{id}/cancel` ends a run nobody is running at once. If a worker is running it, `cancelRequested` turns `true` and it ends when its next round starts. A run that already ended answers `409 run_finished`.

```bash title="cURL"
curl -X POST https://api.lelemon.cl/v2/runs/run_.../cancel \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl "https://api.lelemon.cl/v2/runs?agentId=agt_..." \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const second = await lelemon.agents.runs.create(agent.id, {
  goal: 'Reconcile the November bank statement with the open invoices.',
  limits: { maxRounds: 6, maxMinutes: 5 },
});
const canceled = await lelemon.runs.cancel(second.id);
const listed = await lelemon.runs.list({ query: { agentId: agent.id } });
```


```text
run {"id":"run_4na1UUjRyyACloSF8pdLLS","status":"running","cancelRequested":true}
    runs [{"id":"run_4na1UUjRyyACloSF8pdLLS","status":"canceled"},{"id":"run_1qcjwUbMC3T7JWVfYFPApU","status":"completed_unverified"}]
```

## Notes

- Your backend gets real signed calls.

## What you see in the dashboard

- **Agent, Activity:** every run with its status, rounds and actor, the question when it waits and the result when it ends. You can answer the question from there.
- **Webhooks, Deliveries:** each `run.*` event with its delivery attempts.

## What's next

- [Give an agent a job that runs on its own](/guides/runs): limits, stop reasons and what keeps a run safe.
- [Approve agent actions](/use-cases/support-with-approvals) for the actions that must wait for a person.
- [Track usage and cap conversations](/guides/usage-and-budgets).
