> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laburen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent - Follow-up Query

> Run the follow-up agent on demand over a conversation and get the message it would send, without waiting for the scheduled worker.

Agents with follow-up enabled re-engage conversations that went quiet. In production a worker does this on a schedule, so testing a follow-up prompt means waiting hours. This endpoint runs the same follow-up agent immediately and returns what it would send.

It runs the two AI steps of the follow-up pipeline:

* **Filter** — classifies whether the conversation still deserves a follow-up or is already resolved
* **Generation** — writes the follow-up message using the agent's saved prompt, or one you pass in the request

Useful for tuning follow-up prompts, running evaluations, and previewing a follow-up before it reaches a customer.

<Note>
  The message is **always generated**, even when the filter says the conversation is resolved. The filter verdict comes back in the response as extra information, so you can see both what would be sent and whether the worker would have sent it.
</Note>

<Warning>
  This endpoint never changes the worker's state. It does not increment the follow-up counter, does not disable follow-up on the conversation, and never delivers the message through WhatsApp, Instagram, Chatwoot or any other channel. The only thing it can write is the generated message itself, and only when you ask for it with `saveToConversation`.
</Warning>

### Path

<ParamField path="agentId" type="string" required>
  The ID of the agent whose follow-up you want to run (CUID format). The agent must belong to your organization.
</ParamField>

### Body

#### Required — choose exactly one conversation source

Send either `conversationId` **or** `messages`. Sending both, or neither, returns `400`.

<ParamField body="conversationId" type="string">
  ID of an existing Laburen conversation. The endpoint reads its **last 24 messages**, the same window the follow-up worker uses.
</ParamField>

<ParamField body="messages" type="array">
  A conversation passed inline, without touching the database. Useful to replay imported conversations or to try a hand-written scenario. Between 1 and 50 messages.

  <Expandable title="Message object">
    <ParamField body="from" type="string" required>
      Who wrote the message. Valid values: `human` (the customer), `agent` (the AI agent).
    </ParamField>

    <ParamField body="text" type="string" required>
      The message content.
    </ParamField>
  </Expandable>
</ParamField>

#### Optional

<ParamField body="prompt" type="string">
  Follow-up instructions that replace the agent's saved follow-up prompt **for this request only**. The agent's configuration in the database is not modified.

  Use it to compare prompt variants without editing the agent from the dashboard. If omitted, the agent's saved prompt is used, and the response tells you which one ran through `promptSource`.

  <Note>
    This is not the agent's full system prompt: it is the instruction fragment that the follow-up agent embeds in its own fixed wrapper, the same field you edit under **Follow-up messages** in the dashboard.
  </Note>
</ParamField>

<ParamField body="skipFilter" type="boolean" default="false">
  If `true`, skips the classification step and only generates the message. Saves one AI call when you are iterating on the generation prompt. The response returns `filter: null`.
</ParamField>

<ParamField body="saveToConversation" type="boolean" default="false">
  If `true`, stores the generated message in the conversation as an agent message, so it shows up in the conversation history. Requires `conversationId`.

  Only allowed on conversations that have no real delivery channel — `dashboard`, `website`, `form` and plain `api` conversations. Conversations from `whatsapp`, `meta`, `chatwoot` or `crmchatsappai` are rejected with `400`, so a test message can never appear in a customer's inbox.

  The saved message carries `metadata.followUpQuery` with `trigger: "manual"`, which is what distinguishes it from a real follow-up sent by the worker.
</ParamField>

<ParamField body="streaming" type="boolean" default="false">
  Only `false` is accepted. This endpoint always answers with a single JSON response; sending `true` returns `400`.
</ParamField>

### Response

<ResponseField name="answer" type="string">
  The follow-up message the agent generated.
</ResponseField>

<ResponseField name="filter" type="object">
  Verdict of the classification step. `null` when `skipFilter` was `true`.

  <Expandable title="Filter object" defaultOpen>
    <ResponseField name="verdict" type="string">
      `follow_up_conversation` if the conversation is still open and deserves a follow-up, `permit` if it looks already resolved, `skip` if the classifier gave no clear answer.
    </ResponseField>

    <ResponseField name="wouldSend" type="boolean">
      Whether the scheduled worker would have delivered this message. `true` only when `verdict` is `follow_up_conversation`.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="prompt" type="string">
  The follow-up instructions actually used for this run.
</ResponseField>

<ResponseField name="promptSource" type="string">
  Where those instructions came from: `request` if you sent `prompt`, `agent` if the agent's saved prompt was used.
</ResponseField>

<ResponseField name="conversationId" type="string">
  The conversation the follow-up ran on. `null` when you passed `messages` inline.
</ResponseField>

<ResponseField name="messageId" type="string">
  ID of the stored message. `null` unless `saveToConversation` was `true`.
</ResponseField>

<ResponseField name="model" type="string">
  The model that generates follow-up messages. Fixed for every agent and not configurable, so tests match production behaviour.
</ResponseField>

<ResponseField name="usage" type="object">
  Token usage of the generation step.

  <Expandable title="Usage object" defaultOpen>
    <ResponseField name="inputTokens" type="integer">
      Tokens sent to the model.
    </ResponseField>

    <ResponseField name="outputTokens" type="integer">
      Tokens produced by the model.
    </ResponseField>
  </Expandable>
</ResponseField>

<Note>
  Every successful call consumes credits from your organization, billed the same way as a follow-up sent by the worker. Only the generation step is billed; the filter is not.
</Note>

### Error Responses

| Status Code | Type                     | Description                                                                                                    |
| ----------- | ------------------------ | -------------------------------------------------------------------------------------------------------------- |
| 400         | Bad Request              | Neither `conversationId` nor `messages` was sent, or both were sent at once.                                   |
| 400         | Bad Request              | `messages` is empty, has more than 50 items, or a message has an invalid `from` value.                         |
| 400         | Bad Request              | `saveToConversation` sent without `conversationId`.                                                            |
| 400         | Bad Request              | `streaming: true`. This endpoint is JSON only.                                                                 |
| 400         | Bad Request              | The agent has no follow-up prompt configured and no `prompt` was sent in the body.                             |
| 400         | Bad Request              | The conversation has no messages.                                                                              |
| 400         | Bad Request              | `saveToConversation` on a conversation with a real delivery channel (WhatsApp, Instagram/Meta, Chatwoot, CRM). |
| 401         | UNAUTHORIZED             | The agent belongs to a different organization.                                                                 |
| 402         | USAGE\_LIMIT             | The organization has no credits available.                                                                     |
| 403         | Forbidden                | Missing or invalid API Key.                                                                                    |
| 404         | NOT\_FOUND               | Agent not found.                                                                                               |
| 404         | CONVERSATION\_NOT\_FOUND | Conversation not found, or it belongs to a different organization.                                             |
| 500         | Internal Error           | The model returned an empty response. The message includes the finish reason.                                  |

<RequestExample>
  ```bash cURL theme={null}
  curl --location --request POST 'https://dashboard.laburen.com/api/agents/<agentId>/follow-up/query' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <API_KEY>' \
  --data-raw '{
      "messages": [
          { "from": "human", "text": "Hi, how much is the Wave?" },
          { "from": "agent", "text": "Hello! It is $120. Do you want the 40cm or the 52cm one?" },
          { "from": "human", "text": "The 52cm. Do you ship to Cordoba?" },
          { "from": "agent", "text": "Yes, it takes 3 business days. Should I reserve one for you?" }
      ]
  }'
  ```

  ```javascript JavaScript theme={null}
  const apiUrl = 'https://dashboard.laburen.com/api';
  const apiKey = '<API_KEY>';
  const agentId = '<agentId>';

  const response = await fetch(`${apiUrl}/agents/${agentId}/follow-up/query`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      // Run the follow-up over an existing conversation
      conversationId: 'clxxxxxxxxxxxxxxxxx',
      // Optional: try a prompt variant without editing the agent
      // prompt: 'Be brief, one sentence, and offer to schedule a call.',
      // Optional: skip the classification step
      // skipFilter: true,
    }),
  });

  const data = await response.json();
  console.log(data.answer);
  console.log(data.filter); // { verdict: 'follow_up_conversation', wouldSend: true }
  ```

  ```python Python theme={null}
  import requests

  api_url = "https://dashboard.laburen.com/api"
  api_key = "<API_KEY>"
  agent_id = "<agentId>"

  response = requests.post(
      f"{api_url}/agents/{agent_id}/follow-up/query",
      headers={
          "Content-Type": "application/json",
          "Authorization": f"Bearer {api_key}",
      },
      json={
          # Run the follow-up over an existing conversation
          "conversationId": "clxxxxxxxxxxxxxxxxx",
          # Optional: try a prompt variant without editing the agent
          # "prompt": "Be brief, one sentence, and offer to schedule a call.",
          # Optional: skip the classification step
          # "skipFilter": True,
      },
  )

  data = response.json()
  print(data["answer"])
  print(data["filter"])
  ```
</RequestExample>

<ResponseExample>
  ```json Response theme={null}
  {
    "answer": "Should I reserve the 52cm Wave for you? Happy to jump on a quick call to confirm the shipping to Cordoba.",
    "filter": {
      "verdict": "follow_up_conversation",
      "wouldSend": true
    },
    "prompt": "Be brief, one sentence, and offer to schedule a call.",
    "promptSource": "request",
    "conversationId": "clxxxxxxxxxxxxxxxxx",
    "messageId": null,
    "model": "gpt-4.1-mini",
    "usage": {
      "inputTokens": 343,
      "outputTokens": 83
    }
  }
  ```
</ResponseExample>

### Comparing prompt variants

Send the same conversation several times with a different `prompt` each time. Nothing is stored, so the agent's configuration stays untouched between runs:

```javascript theme={null}
const variants = [
  'Be brief and ask if they still need help.',
  'Be warm, mention the shipping time, and offer a call.',
  'Be direct: ask for a yes or no on the purchase.',
];

for (const prompt of variants) {
  const response = await fetch(`${apiUrl}/agents/${agentId}/follow-up/query`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      conversationId: 'clxxxxxxxxxxxxxxxxx',
      prompt,
      skipFilter: true, // the verdict does not change between prompt variants
    }),
  });

  const { answer } = await response.json();
  console.log(prompt, '→', answer);
}
```

### Continuing a conversation with the follow-up in it

To simulate the full production flow, seed a conversation with the [Query](/api-reference/endpoint/agents/query) endpoint, store the follow-up in it, and keep chatting. The follow-up becomes part of the conversation context, exactly as it would in production:

```javascript theme={null}
// 1. Two turns with the main agent
const first = await queryAgent('Hi, how much is the Wave?');
const conversationId = first.conversationId;
await queryAgent('Do you ship to Cordoba? I will confirm later.', conversationId);

// 2. The customer goes quiet: generate the follow-up and store it
const followUp = await fetch(`${apiUrl}/agents/${agentId}/follow-up/query`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  },
  body: JSON.stringify({ conversationId, saveToConversation: true }),
}).then((r) => r.json());

console.log('Follow-up:', followUp.answer, '| stored as', followUp.messageId);

// 3. The customer comes back: the agent already has the follow-up in context
await queryAgent('Yes, still interested. Send me the link.', conversationId);
```
