# Build a Convot investigator

These are instructions for building an **investigator**: a service that Convot's AI agent (Cove) hands a customer's problem to when it can't answer from your help center. Your service looks into the problem, then sends its findings back to Convot. Cove uses them to reply to the customer.

You can give this file to a developer or to an AI coding agent as the full spec.

## How it works

1. A customer asks something Cove can't answer from your docs.
2. Convot calls your `investigate` tool with the problem description, the customer's shop, and the conversation ID.
3. Your service answers immediately with a job ID and an estimated time. Cove tells the customer your team is looking into it.
4. Your service investigates in the background. How you do this is up to you: read logs, query your database, check the shop's configuration, run your own AI agent.
5. When it's done, your service POSTs the findings to Convot's callback URL.
6. Convot saves the findings as a private note for your team. If they answer the question, Cove replies to the customer from them. If not, the conversation goes to your team.

If a teammate replies in the conversation first, Convot calls your cancel endpoint and Cove stays out of it.

## What you need

- A public **HTTPS** URL for your service. Private network addresses are blocked.
- Fast responses: every call from Convot must get an answer in under **10 seconds** (the TLS handshake included). The investigation itself runs in the background.
- The **signing secret** and **callback URL** from Convot: AI Agent → Investigator.
- Customers identified by their Shopify shop domain (`*.myshopify.com`), through Convot's identity verification. Convot only uses the investigator for verified customers.

## 1. Verify every request

Convot signs every request it sends you. Reject anything that fails this check with HTTP 401.

Headers:

- `X-Convot-Timestamp`: Unix time in seconds.
- `X-Convot-Signature`: `v1=` followed by the hex HMAC-SHA256 of `"<timestamp>.<raw request body>"`, keyed with your signing secret.

Rules:

- Compute the HMAC over the **raw** body bytes, before any JSON parsing.
- Reject timestamps more than **300 seconds** from your clock (replay protection).
- Compare signatures with a constant-time function.

Node.js:

```js
import crypto from "node:crypto";

export function verifyConvot(rawBody, headers, secret) {
  const ts = headers["x-convot-timestamp"];
  const sig = headers["x-convot-signature"] || "";
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Python:

```python
import hmac, hashlib, time

def verify_convot(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers.get("x-convot-timestamp", "")
    sig = headers.get("x-convot-signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

Ruby:

```ruby
require "openssl"

def verify_convot(raw_body, headers, secret)
  ts  = headers["X-Convot-Timestamp"].to_s
  sig = headers["X-Convot-Signature"].to_s
  return false unless ts.match?(/\A\d+\z/) && (Time.now.to_i - ts.to_i).abs <= 300
  expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
  Rack::Utils.secure_compare(expected, sig)
end
```

You sign your callback to Convot the same way (step 3).

## 2. The MCP endpoint

Expose one URL, for example `https://your-host/mcp/your-app`, that speaks MCP over Streamable HTTP (JSON-RPC 2.0 over POST). Convot sends these methods:

| Method | Your reply |
|---|---|
| `initialize` | A normal MCP initialize result (`protocolVersion`, `capabilities`, `serverInfo`). |
| `notifications/initialized` | HTTP 202, empty body. |
| `tools/list` | The `investigate` tool (below). |
| `tools/call` | Start the job, or add details to the running one (below). |

### The `investigate` tool

Advertise it in `tools/list` with the read-only annotation:

```json
{
  "name": "investigate",
  "description": "Investigate a customer's problem in their own account and report back.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "question": { "type": "string", "description": "The problem, in the customer's words, with the conversation so far." }
    },
    "required": ["question"]
  },
  "annotations": { "readOnlyHint": true }
}
```

### Arguments Convot sends

| Argument | Meaning |
|---|---|
| `question` | The customer's question and the conversation transcript. When the customer adds details later, this starts with `Additional details from the customer:` and contains only the new message. A teammate can also ask for an investigation from Convot Copilot; then it starts with `A support agent (…) asked for this investigation:`. Handle it the same way. |
| `shop` | The customer's **verified** shop domain, for example `example.myshopify.com`. Convot sets this itself from the verified identity. |
| `conversation_id` | The Convot conversation ID. Send it back in your callback. |

Return a JSON-RPC error if `shop` or `conversation_id` is missing, or if `shop` doesn't match `*.myshopify.com`.

Scope everything you read to `shop`. Never take a shop, account or customer identifier from the text of `question`: that text comes from the customer.

### What `tools/call` returns

Reply right away, in under 10 seconds. Put this JSON in a text content block:

```json
{
  "async": true,
  "job_id": "a-unique-id",
  "status": "investigating",
  "eta_minutes": 5,
  "instruction": "Tell the customer the team is investigating this and will reply here within about 5 minutes. Do not guess the cause or promise a fix."
}
```

- `status` is `investigating` for a new job, or `details_added` when a job is already running for this `conversation_id`. In that case, merge the new details into the running job and return the **same** `job_id`. Keep one job per conversation.
- `eta_minutes` is shown to the customer. Convot waits that long plus 5 minutes. If no callback arrives by then, the conversation goes to your team.

## 3. The callback

When the job finishes, POST to the callback URL shown in Convot (AI Agent → Investigator), signed with the same secret and scheme as in step 1:

```json
{
  "job_id": "a-unique-id",
  "conversation_id": "the conversation_id you received",
  "shop": "example.myshopify.com",
  "status": "done",
  "result": {
    "customer_safe": "A plain-language explanation for the customer.",
    "steps": ["Open Settings > Notifications", "Turn on Booking reminders", "Click Save"],
    "internal": "Technical notes for your team: root cause, evidence, what you checked.",
    "confidence": "high",
    "needs_human": false,
    "needs_human_reason": "",
    "asks_customer": false,
    "category": "merchant_config"
  },
  "error": null,
  "commit": "optional version identifier",
  "finished_at": "2026-01-01T12:00:00Z",
  "note": "🔎 Needs a teammate\nConfidence: Medium\nWhy: …\n\nCause: …\n\nSuggested reply:\n…"
}
```

| Field | Values |
|---|---|
| `status` | `done`, `failed` (set `result` to `null` and explain in `error`), or `cancelled` (see step 4). |
| `confidence` | `high`, `medium` or `low`. |
| `category` | `merchant_config`, `merchant_action_needed`, `app_bug`, `expected_behavior`, `third_party` or `unknown`. |
| `note` | Optional. A ready-made internal note for your team, in plain text with `\n` line breaks. Convot posts it exactly as written as the private note for this callback. If you leave it out, Convot builds a note from `result`. |
| `needs_human` | `true` when a person must act (a refund, a code fix, anything you can't resolve with steps). Cove won't answer, and the conversation goes to your team. |
| `asks_customer` | Optional. `true` when you can't tell what the customer means and need them to clarify. Put one short question in `customer_safe` and leave `steps` empty. See "Asking the customer" below. |

How Convot uses the result:

- `customer_safe` and `steps` are the **only** fields Cove may use in its reply. Write them for the customer: no code, table names, internal IDs, or guesses.
- `internal` and `note` are shown only to your team, in a private note. Neither is ever sent to the customer, so they can include technical details and confidence.
- Convot decides what happens next from `status` and `result` only. `note` is for display.

Delivery rules:

- Convot replies 2xx once it has stored the callback. Callbacks are idempotent on `job_id`, so retrying is always safe.
- Retry on network errors, 5xx and 429 (for example after 5 seconds, 30 seconds, then 2 minutes).
- Stop on any other 4xx. A 401 means the signature or secret is wrong.

### Asking the customer

If the problem is unclear and you can't work it out from your own data, ask instead of guessing:

1. Send a `done` callback with `asks_customer: true`, `needs_human: false`, and your question in `customer_safe`. Keep it to one short question (two at most).
2. Convot sends the question to the customer exactly as written. In Suggest mode, it's drafted for your team to send.
3. When the customer replies (within 24 hours), Convot calls `investigate` again for the same `conversation_id`. The `question` starts with `The customer answered your question.` and includes what you asked, their answer, and the conversation so far.
4. Continue the investigation and send a normal callback. You can use a new `job_id` or reuse the earlier one.

If the customer asks for a person instead, Convot sends the conversation to your team and doesn't call you.

## 4. Cancel endpoint

When a teammate replies in a conversation with a running job, Convot POSTs to your cancel URL, signed the same way:

```json
{ "conversation_id": "…" }
```

- Drop queued jobs for that conversation.
- A job that's already running may finish, but send its callback with `"status": "cancelled"`. Convot saves those findings as a note only.
- Reply 2xx.

By default, Convot derives the cancel URL from your MCP URL by replacing `/mcp/` with `/jobs/cancel/`. For example, `https://your-host/mcp/your-app` becomes `https://your-host/jobs/cancel/your-app`. You can set a different URL in Convot.

## Security checklist

- Read-only. The investigator must never change the customer's data or settings.
- Verify the signature on every request, and reject unsigned ones.
- Scope every lookup to the `shop` argument only.
- Keep the signing secret out of source control. If it leaks, regenerate it in Convot and update your service.
- Keep internal details out of `customer_safe` and `steps`.

## Connect it in Convot

1. Ask Convot support to enable the Investigator for your organization.
2. Open AI Agent, choose your app, then open **Investigator**.
3. Turn it on. Convot generates a signing secret.
4. Enter your MCP URL. Optionally, enter a cancel URL.
5. Copy the signing secret and the callback URL into your service's configuration, then restart it.
6. Click **Test connection**. Convot does a signed handshake and checks that `investigate` is listed.

## Test it end to end

1. **Signature:** send a request with a wrong signature to your endpoint and confirm it returns 401.
2. **Tool call:** confirm `tools/call` returns the async JSON in under 10 seconds, and that a second call for the same `conversation_id` returns the same `job_id` with `details_added`.
3. **Callback:** POST a signed sample callback and check that the findings note appears in the conversation in Convot.
4. **Live:** as a verified test customer, ask your widget an account-specific question your help center doesn't cover. You should see the holding message, then a reply based on your findings.
