# AI API

Source: https://docs.externa.qiick.io/docs/ai-api

AI HTTP routes live in `routes/ai.php`: one **HMAC-signed webhook** (no session) and an authenticated `/ai/*` group gated by `can-use-ai`.

Config: [AI configuration](/docs/ai-configuration). Product behaviour: [AI assistant](/docs/ai-assistant).

## Webhook (HMAC auth)

| Method | Path                             | Route name                      | Middleware      |
| ------ | -------------------------------- | ------------------------------- | --------------- |
| `POST` | `/ai/webhooks/collection-import` | `ai.webhooks.collection-import` | `throttle:30,1` |

CSRF stays excepted for this path in `bootstrap/app.php` (machine clients have no session CSRF cookie). Auth is **HMAC only** — not a bare Bearer token.

### Authentication

Verified in `CollectionImportWebhookRequest::authorize()` **before** validation runs (controller helper: `CollectionImportWebhookController::sign`). Signature check first — unsigned or mistimed requests never reach the body rules ([core#185](https://github.com/qiick-io/externa-core/issues/185)).

1. Build acceptable tokens: current `config('ai.webhook_token')` ← `AI_WEBHOOK_TOKEN`, plus a non-expired previous token during rotation grace (`AI_WEBHOOK_TOKEN_PREVIOUS` / `AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT`).
2. If **no** acceptable token is configured → abort **401** (`Webhook signing is not configured.`).
3. Require header `X-AI-Webhook-Timestamp` (unix seconds). Abort **401** if missing/non-digit or skew over **300s** vs server clock.
4. Read header `X-AI-Webhook-Signature` (`sha256=` + hex digest).
5. Compute HMAC-SHA256 over **`"{timestamp}.{rawBody}"`** for each acceptable token (`hash_equals`).
6. Abort **401** if none match (`Invalid webhook signature.`).
7. Cache the signature as a nonce for 300s — a second delivery with the same signature aborts **401** (`Webhook replay detected.`).

**Unsigned / mistimed / replayed requests are rejected in every environment** — including `local`. There is no empty-token bypass. Set `AI_WEBHOOK_TOKEN` wherever you call the webhook (local included).

Changing any body byte (including `collection_id` inside the JSON) or the timestamp invalidates the digest. Bearer / `X-AI-Webhook-Token` alone is rejected.

### Body (validated)

Expects a collection id, associative `records` array, and optional `upsert_key`. On success returns JSON with `ok`, `collection_id`, `url`, and import summary fields. Validation failures / import errors return 4xx JSON.

> From externa-core **1.2.2** ([#214](https://github.com/qiick-io/externa-core/pull/214)): sign the **raw request body only** (`sha256=` + HMAC-SHA256). The previous body + newline + `collection_id` payload from [#86](https://github.com/qiick-io/externa-core/issues/86) is no longer accepted. Invalid or missing signatures return **401** (was 403).

> From externa-core **1.3.0** ([#185](https://github.com/qiick-io/externa-core/issues/185) / [#207](https://github.com/qiick-io/externa-core/issues/207)): an empty `AI_WEBHOOK_TOKEN` fails closed in **every** `APP_ENV`, including `local`. Unsigned requests are never accepted.

> The webhook has no interactive user; AI mutation logging is skipped for this path. Protect `AI_WEBHOOK_TOKEN` like a production secret.

> From externa-core **1.4.0** ([#273](https://github.com/qiick-io/externa-core/issues/273)): send `X-AI-Webhook-Timestamp` and sign `"{timestamp}.{rawBody}"`. Body-only HMAC is no longer accepted. Replayed signatures within the skew window are rejected via nonce.

```http
POST /ai/webhooks/collection-import HTTP/1.1
Host: app.example.com
X-AI-Webhook-Timestamp: 1730380800
X-AI-Webhook-Signature: sha256=…
Content-Type: application/json
```

```php
$rawBody = json_encode($payload, JSON_THROW_ON_ERROR);
$timestamp = (string) time();
$signature = 'sha256='.hash_hmac(
    'sha256',
    $timestamp.'.'.$rawBody,
    env('AI_WEBHOOK_TOKEN'),
);
// Also send header X-AI-Webhook-Timestamp: $timestamp
```

### Rotate the webhook token

Use `php artisan ai:rotate-webhook-token` ([core#207](https://github.com/qiick-io/externa-core/issues/207)) when the HMAC secret may have leaked or on a regular rotation cadence.

| Option        | Purpose                                                                                                          |
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
| `--grace=24h` | How long the **previous** token stays accepted (`30m`, `24h`, `7d`, …). Default `24h`.                           |
| `--show`      | Print the new token once to stdout.                                                                              |
| `--write-env` | Persist `AI_WEBHOOK_TOKEN`, `AI_WEBHOOK_TOKEN_PREVIOUS`, and `AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT` into `.env`. |
| `--force`     | Required with `--write-env` when `APP_ENV` looks like production.                                                |

Pass **`--show` and/or `--write-env`** (otherwise the command exits without rotating). During the grace window, both current and previous tokens verify successfully.

```bash
# Local / staging — write .env and print the new value once
php artisan ai:rotate-webhook-token --grace=24h --write-env --show

# Production — write .env only with --force; prefer secret store + env inject when possible
php artisan ai:rotate-webhook-token --grace=24h --write-env --force --show
```

> After changing `.env`, run `php artisan config:clear` (or rebuild `config:cache`) and **restart PHP-FPM / queue workers / containers** so the new token is loaded. The command prints the same reminder. Stale cached config keeps verifying with the old secret.

Env keys: [Environment variables — AI](/docs/environment-variables#ai).

---

## Authenticated group

Middleware stack for all routes below:

```text
auth + verified + permission:can-use-ai
```

Prefix: `/ai`, name prefix: `ai.`.

### Status and shell

| Method | Path         | Route name  | Notes                                            |
| ------ | ------------ | ----------- | ------------------------------------------------ |
| `GET`  | `/ai`        | `ai.index`  | Inertia AI page shell                            |
| `GET`  | `/ai/status` | `ai.status` | Provider / readiness JSON (`AiStatusController`) |

### Conversations list / create / show / destroy / pin / truncate / bulk-destroy

| Method   | Path                                        | Route name                      | Purpose                      |
| -------- | ------------------------------------------- | ------------------------------- | ---------------------------- |
| `GET`    | `/ai/conversations`                         | `ai.conversations.index`        | List conversations           |
| `POST`   | `/ai/conversations`                         | `ai.conversations.store`        | Create conversation          |
| `POST`   | `/ai/conversations/bulk-destroy`            | `ai.conversations.bulk-destroy` | Delete many                  |
| `GET`    | `/ai/conversations/{conversation}`          | `ai.conversations.show`         | Show conversation + messages |
| `DELETE` | `/ai/conversations/{conversation}`          | `ai.conversations.destroy`      | Delete one                   |
| `POST`   | `/ai/conversations/{conversation}/pin`      | `ai.conversations.pin`          | Toggle pin                   |
| `POST`   | `/ai/conversations/{conversation}/truncate` | `ai.conversations.truncate`     | Truncate message history     |

Persistence tables: `agent_conversations`, `agent_conversation_messages` ([AI configuration](/docs/ai-configuration)).

Controller: `AiConversationController`.

### Attachments

| Method | Path              | Route name             | Throttle        |
| ------ | ----------------- | ---------------------- | --------------- |
| `POST` | `/ai/attachments` | `ai.attachments.store` | `throttle:30,1` |

Uploads chat attachments (CSV/TXT/XLSX/PDF per assistant instructions). Expired files are cleaned by `ai:cleanup-attachments`.

Controller: `AiChatAttachmentController`.

### Chat

| Method | Path       | Route name | Throttle        |
| ------ | ---------- | ---------- | --------------- |
| `POST` | `/ai/chat` | `ai.chat`  | `throttle:30,1` |

Invokes `AppAssistant` with tools filtered by the user’s effective permissions. Optional daily cap: `AI_DAILY_PROMPT_LIMIT` (`0` = unlimited).

Controller: `AiChatController`.

### Import job status

| Method | Path                      | Route name            | Extra permission         |
| ------ | ------------------------- | --------------------- | ------------------------ |
| `GET`  | `/ai/import-jobs/{jobId}` | `ai.import-jobs.show` | `can-create-collections` |

`jobId` must be a **UUID**. Used to poll async collection import jobs started from AI tools.

Controller: `ImportJobStatusController`.

### Conversation deep link

Registered **last** so static `/ai/*` paths are not captured:

| Method | Path                 | Route name | Constraint            |
| ------ | -------------------- | ---------- | --------------------- |
| `GET`  | `/ai/{conversation}` | `ai.show`  | `{conversation}` UUID |

Inertia page for opening a specific conversation (`AiPageController@show`).

## Throttles summary

| Endpoint          | Limit                |
| ----------------- | -------------------- |
| Webhook import    | 30 requests / minute |
| Attachments store | 30 / minute          |
| Chat              | 30 / minute          |

Other authenticated AI routes rely on session auth + Fortify login throttles unless additional limiters are added.

## Tool permissions (not HTTP middleware)

Even with `can-use-ai`, individual tools still call `ChecksAiPermissions::requirePermission()` for domain actions (files, collections, users, …). Users without matching effective permissions get English machine-error strings from tools (the model should translate them into the user’s language) instead of silent success. See [Extending Externa](/docs/extending).
