# Admin Assistant — REST API Reference

LLM-powered admin assistant accessed via REST. Send natural language queries about security incidents, jobs, users, groups, and metrics. Responses are generated by Claude using permission-gated tools.

## Requirements

- Feature must be enabled server-side (`LLM_ADMIN_ENABLED = True`)
- User must have `view_admin` permission to access all endpoints
- Tool access depends on the user's additional permissions (see [Permission Mapping](#permission-mapping))

## Endpoints

### Send Message

```
POST /api/assistant
```

Send a message to the assistant and receive an LLM-generated response.

**Permission**: `view_admin`
**Rate limit**: 60/ip, 30/device

**Request body**:

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | Yes | Natural language query |
| `conversation_id` | integer | No | Continue an existing conversation |

**Response** (success):

```json
{
    "status": true,
    "data": {
        "response": "Here are the failed jobs in the last hour. The failure rate is 12%.",
        "conversation_id": 42,
        "tool_calls_made": [
            {"tool": "query_jobs", "input": {"status": "failed", "minutes": 60}}
        ],
        "blocks": [
            {
                "type": "table",
                "title": "Failed Jobs (Last Hour)",
                "columns": ["ID", "Function", "Channel", "Error"],
                "rows": [
                    ["abc123", "send_email", "email", "SMTP timeout"],
                    ["def456", "process_file", "default", "File not found"]
                ]
            }
        ]
    }
}
```

The `blocks` array is only present when the LLM includes structured data. See [Structured Data Blocks](#structured-data-blocks) for details.

**Response** (feature disabled — HTTP 404):

```json
{
    "status": false,
    "error": "Assistant is not enabled"
}
```

**Response** (no API key — HTTP 503):

```json
{
    "status": false,
    "error": "LLM API key not configured"
}
```

**Response** (invalid `conversation_id` — HTTP 404):

```json
{
    "status": false,
    "error": "Conversation not found",
    "conversation_id": null
}
```

**Example queries**:

```
"Show me failed jobs in the last hour"
"What's the incident trend this week?"
"Which users were rate-limited today?"
"How many open incidents are there by category?"
"What permissions does user john@example.com have?"
"Block IP 1.2.3.4 for brute force attacks"
```

---

### List Conversations

```
GET /api/assistant/conversation
```

List the requesting user's past conversations. Admins see all conversations; non-admins see only their own.

**Permission**: `view_admin`

**Query parameters**: Standard RestMeta pagination (`limit`, `page`, `order_by`).

**Response**:

```json
{
    "status": true,
    "data": [
        {
            "id": 42,
            "title": "Show me failed jobs",
            "created": "2026-04-01 14:30:00+00:00",
            "modified": "2026-04-01 14:32:15+00:00"
        }
    ]
}
```

---

### Get Conversation

```
GET /api/assistant/conversation/<id>
GET /api/assistant/conversation/<id>?graph=detail
```

Get a conversation. Use `?graph=detail` to include the full message history. Without it, the response contains only the conversation fields (no messages).

**Permission**: `view_admin` + owner

**Response** (default graph — no messages):

```json
{
    "status": true,
    "data": {
        "id": 42,
        "title": "Show me failed jobs",
        "created": "2026-04-01 14:30:00+00:00",
        "modified": "2026-04-01 14:32:15+00:00"
    }
}
```

**Response** (`?graph=detail` — includes messages):

```json
{
    "status": true,
    "data": {
        "id": 42,
        "title": "Show me failed jobs",
        "created": "2026-04-01 14:30:00+00:00",
        "modified": "2026-04-01 14:32:15+00:00",
        "messages": [
            {
                "id": 1,
                "role": "user",
                "content": "Show me failed jobs in the last hour",
                "tool_calls": null,
                "blocks": null,
                "created": "2026-04-01 14:30:00+00:00"
            },
            {
                "id": 2,
                "role": "assistant",
                "content": "I found 3 failed jobs in the last hour...",
                "tool_calls": null,
                "blocks": [
                    {"type": "table", "title": "Failed Jobs", "columns": ["ID", "Error"], "rows": [["abc", "timeout"]]}
                ],
                "created": "2026-04-01 14:30:05+00:00"
            },
            {
                "id": 3,
                "role": "tool_result",
                "content": "",
                "tool_calls": [{"type": "tool_result", "tool_use_id": "...", "content": "..."}],
                "blocks": null,
                "created": "2026-04-01 14:30:03+00:00"
            }
        ]
    }
}
```

**Message fields**:

| Field | Description |
|---|---|
| `id` | Message ID |
| `role` | `user`, `assistant`, `tool_use`, or `tool_result` |
| `content` | Text content. For assistant messages, block fences are already stripped. |
| `tool_calls` | Raw tool_use/tool_result data. `null` for non-tool messages. |
| `blocks` | Pre-parsed structured data blocks (table/chart/stat). `null` when not present. Stored at write time — never re-parsed on read. |
| `created` | ISO timestamp |

**Response** (not found or not owner — HTTP 404):

```json
{
    "status": false,
    "error": "not found"
}
```

---

### Delete Conversation

```
DELETE /api/assistant/conversation/<id>
```

Delete a conversation and all its messages. The conversation owner or any user with `view_admin` can delete.

**Permission**: `view_admin` + owner (via `CAN_DELETE`)

**Response**:

```json
{
    "status": true
}
```

**Response** (not found or not owner — HTTP 404):

```json
{
    "status": false,
    "error": "not found"
}
```

---

## Permission Mapping

The assistant checks the user's permissions before executing each tool. The tools available to a user depend on their permissions:

| Permission | Tools Available |
|---|---|
| `view_security` | Query incidents, events, event counts, tickets, rulesets, IP history, incident timeline, incident trends, list event categories |
| `manage_security` | All `view_security` tools + update incident, block IP, create ticket |
| `view_jobs` | Query jobs, job events, job logs, job stats, queue health, list job channels |
| `manage_jobs` | All `view_jobs` tools + cancel job, retry job |
| `view_admin` | Query users, user detail, user activity, rate limits, permission summary, fetch metrics, system health, list tools, list metric categories/slugs, list permissions |
| `view_groups` | Query groups, group detail, group members, group activity |

Users without any of these permissions will receive: `"You don't have permissions for any assistant tools."`

If the LLM tries to call a tool the user doesn't have permission for, the tool returns a permission error to the LLM (not to the client), and the LLM explains what permission is needed.

## Multi-turn Conversations

The assistant maintains conversation context across multiple requests. To continue a conversation, pass the `conversation_id` from the previous response:

```javascript
// First message
const resp1 = await fetch('/api/assistant', {
    method: 'POST',
    headers: {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'},
    body: JSON.stringify({message: 'Show me open incidents'})
});
const data1 = await resp1.json();
const convId = data1.data.conversation_id;

// Follow-up in the same conversation
const resp2 = await fetch('/api/assistant', {
    method: 'POST',
    headers: {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'},
    body: JSON.stringify({
        message: 'Now show me the timeline for the first one',
        conversation_id: convId
    })
});
```

## Mutating Operations

Tools that modify data (block IP, cancel job, update incident, create ticket) require `manage_*` permissions. The LLM is instructed to confirm with the user before executing these, so the typical flow is:

1. User: "Block IP 1.2.3.4"
2. Assistant: "I'll block IP 1.2.3.4 with a 1-hour TTL. Confirm?"
3. User: "Yes"
4. Assistant: "Done. IP 1.2.3.4 has been blocked for 3600 seconds."

This confirmation happens via conversation turns, not a separate API call.

## WebSocket Interface

For real-time chat UIs, the assistant supports a WebSocket interface alongside REST. Conversation CRUD stays as REST; the actual chat flows over the existing realtime WebSocket connection.

### Sending a Message

```javascript
ws.send(JSON.stringify({
    type: 'assistant_message',
    message: 'Show me failed jobs in the last hour',
    conversation_id: 42  // optional — omit to start a new conversation
}));
```

### Response Events

The server publishes events to the user's WebSocket topic as the assistant processes:

| Event | When | Payload |
|---|---|---|
| `assistant_thinking` | Immediately after message received | `{conversation_id}` |
| `assistant_tool_call` | Each time a tool is called | `{conversation_id, tool, input}` |
| `assistant_response` | Final LLM response | `{conversation_id, message_id, created, response, tool_calls_made, blocks}` |
| `assistant_error` | On failure | `{conversation_id, error}` |

**`assistant_response` payload fields**:

| Field | Type | Description |
|---|---|---|
| `conversation_id` | integer | Conversation the message belongs to |
| `message_id` | integer | ID of the saved `Message` record |
| `created` | string | ISO 8601 timestamp of when the message was saved |
| `response` | string | Narrative text with block fences already stripped |
| `tool_calls_made` | array | List of `{tool, input}` objects for tools called during this turn |
| `blocks` | array or null | Parsed structured-data blocks (`table`, `chart`, `stat`). `null` when the response contains no blocks. Always present — never absent. |

The `message_id` and `created` fields make the WS event consistent with the REST detail graph shape, allowing the client to correlate WS events with conversation history retrieved via `GET /api/assistant/conversation/<id>?graph=detail`.

### Client Wiring Example

```javascript
// Subscribe to assistant events
ws.on('assistant_thinking', (data) => {
    showThinkingIndicator(data.conversation_id);
});

ws.on('assistant_tool_call', (data) => {
    showToolCallStatus(data.tool, data.input);
});

ws.on('assistant_response', (data) => {
    hideThinkingIndicator();
    appendAssistantMessage(data.conversation_id, data.response, {
        messageId: data.message_id,
        created: data.created,
    });
    if (data.blocks) {
        renderBlocks(data.blocks);  // see Structured Data Blocks section
    }
});

ws.on('assistant_error', (data) => {
    hideThinkingIndicator();
    showError(data.error);
});
```

### How It Works

1. Client sends `assistant_message` via WebSocket
2. Server validates permissions, stores the user message, returns `assistant_thinking` immediately
3. A background thread runs the LLM agent (tool-calling loop may take seconds)
4. During processing, `assistant_tool_call` events are published for each tool
5. When done, `assistant_response` (or `assistant_error`) is published

All events arrive in the same format — `{"type": "assistant_*", ...}` directly on the WebSocket. There is no `{"type": "message", "data": ...}` wrapper to unwrap.

The REST endpoints (`GET /api/assistant/conversation`, etc.) continue to work for listing and retrieving conversation history.

## Structured Data Blocks

Responses may include a `blocks` array containing structured data for rendering as tables, charts, or stat cards. In REST responses the `blocks` key is only present when the LLM includes structured data. In WebSocket `assistant_response` events, `blocks` is always present — it is `null` when the response has no blocks and an array otherwise.

The `response` text field contains the narrative with block fences already stripped. The frontend renders text and blocks together.

### Block Types

#### `table`

For query results, lists, comparisons.

```json
{
    "type": "table",
    "title": "Failed Jobs (Last Hour)",
    "columns": ["ID", "Function", "Channel", "Error", "Time"],
    "rows": [
        ["abc123", "send_email", "email", "SMTP timeout", "14:23"],
        ["def456", "process_file", "default", "File not found", "14:31"]
    ]
}
```

| Field | Type | Description |
|---|---|---|
| `title` | string | Table heading |
| `columns` | string[] | Column headers (Title Case) |
| `rows` | any[][] | Row data, one array per row |

#### `chart`

For time-series, trends, distributions.

```json
{
    "type": "chart",
    "chart_type": "line",
    "title": "Login Attempts (7d)",
    "labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
    "series": [
        {"name": "attempts", "values": [120, 145, 132, 198, 210, 89, 95]},
        {"name": "failures", "values": [3, 5, 2, 12, 8, 1, 2]}
    ]
}
```

| Field | Type | Description |
|---|---|---|
| `chart_type` | string | `line`, `bar`, `pie`, or `area` |
| `title` | string | Chart heading |
| `labels` | string[] | X-axis labels (or pie slice labels) |
| `series` | object[] | Data series, each with `name` and `values` array |

#### `stat`

For dashboard-style key metrics.

```json
{
    "type": "stat",
    "items": [
        {"label": "Open Incidents", "value": 42},
        {"label": "Failed Jobs (24h)", "value": 7},
        {"label": "Active Users", "value": 156}
    ]
}
```

| Field | Type | Description |
|---|---|---|
| `items` | object[] | Each with `label` (string) and `value` (string or number) |

### Rendering Example

```javascript
function renderBlocks(blocks) {
    for (const block of blocks) {
        switch (block.type) {
            case 'table':
                renderTable(block.title, block.columns, block.rows);
                break;
            case 'chart':
                renderChart(block.chart_type, block.title, block.labels, block.series);
                break;
            case 'stat':
                renderStatCards(block.items);
                break;
        }
    }
}
```
