# 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"
"Fetch https://example.com/docs/api and summarize the authentication section"
"Show me all active users created in the last 7 days"
"How many incidents are in 'open' status?"
"Export the last 50 jobs to CSV"
"Show me all errors in the last hour"
"What did user 42 do today?"
```

---

### Create Context Conversation

```
POST /api/assistant/context
```

Create a conversation pre-loaded with the full context of any model instance. Use this for "Open in Assistant" buttons on detail views.

**Permission**: `view_admin` + the model's own `VIEW_PERMS`

**Request body**:

| Field | Type | Required | Description |
|---|---|---|---|
| `model` | string | Yes | Model identifier in `app_label.ModelName` format (e.g. `incident.Ticket`, `incident.Incident`) |
| `pk` | integer | Yes | Primary key of the instance |

**Response** (success — new conversation):

```json
{
    "status": true,
    "data": {
        "conversation_id": 789
    }
}
```

**Response** (success — existing conversation found):

```json
{
    "status": true,
    "data": {
        "conversation_id": 789,
        "existing": true
    }
}
```

**Duplicate prevention**: If the same user has already opened an assistant conversation for the same model + pk, the existing conversation is returned instead of creating a new one.

**Errors**:

| Status | Condition |
|---|---|
| 400 | Invalid model format or model not found |
| 403 | User lacks `view_admin` or model `VIEW_PERMS` |
| 404 | Instance with given pk not found |

**Example — open assistant from a ticket detail view**:

```javascript
async function openAssistant(ticketId) {
    const resp = await api.post('/api/assistant/context', {
        model: 'incident.Ticket',
        pk: ticketId,
    });
    if (resp.data.status) {
        assistantPanel.open(resp.data.data.conversation_id);
    }
}
```

After creation, use the standard `POST /api/assistant` or WebSocket `assistant_message` with the returned `conversation_id` to continue the conversation.

---

### 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"
}
```

---

---

### Memory Endpoints

The assistant exposes REST endpoints to read, write, and delete memory entries. These are independent of any conversation — they operate directly on the Redis-backed memory store.

#### List All Memory Tiers

```
GET /api/assistant/memory
```

Returns all memory entries the user can see, grouped by tier.

**Permission**: `assistant`

**Response**:

```json
{
    "status": true,
    "data": {
        "global": {
            "platform": "Healthcare SaaS (HIPAA-compliant) on AWS us-east-1",
            "internal_ips": "Never block 10.0.0.0/8 or 172.16.0.0/12"
        },
        "user": {
            "preferred_channel": "Prefers Slack for non-critical alerts"
        },
        "group": {
            "deploy_window": "Deploys run Tuesdays 9-11 PM UTC only"
        }
    }
}
```

Empty tiers are omitted from the response. If the user has no group context, the `group` key will be absent.

#### List Single Tier

```
GET /api/assistant/memory/<tier>
```

Where `tier` is `global`, `user`, or `group`.

**Permission**: `assistant`

**Response**:

```json
{
    "status": true,
    "data": {
        "platform": "Healthcare SaaS (HIPAA-compliant) on AWS us-east-1"
    }
}
```

**Error** (invalid tier — HTTP 400):

```json
{"status": false, "error": "Invalid tier: unknown"}
```

#### Write Memory Entry

```
POST /api/assistant/memory/<tier>
```

Create or update a memory entry.

**Permission**: `assistant`

**Request body**:

| Field | Type | Required | Description |
|---|---|---|---|
| `key` | string | Yes | Slug key: lowercase, alphanumeric, colons/underscores/hyphens, max 64 chars |
| `value` | string | Yes | Entry content. Plain text, max 500 characters. |

**Response** (created):

```json
{"status": true, "data": {"status": "created", "key": "platform", "tier": "global"}}
```

**Response** (updated):

```json
{"status": true, "data": {"status": "updated", "key": "platform", "tier": "global"}}
```

**Errors** (HTTP 400):

| Condition | Error |
|---|---|
| Invalid tier | `"Invalid tier: unknown"` |
| Invalid key format | `"Key must be lowercase alphanumeric with colons, underscores, or hyphens (max 64 chars)"` |
| Value too long | `"Value too long (N chars). Maximum is 500"` |
| Secret detected | `"Value appears to contain a secret (API key, password, token, etc.)..."` |
| Tier full | `"Memory full (N/max entries). Delete an entry before adding a new one."` |
| Permission denied | `"Permission denied for global memory"` |

#### Delete Memory Entry

```
DELETE /api/assistant/memory/<tier>/<key>
```

**Permission**: `assistant`

**Response**:

```json
{"status": true, "data": {"status": "deleted", "key": "platform", "tier": "global"}}
```

**Errors** (HTTP 400):

| Condition | Error |
|---|---|
| Invalid tier | `"Invalid tier: unknown"` |
| Key not found | `"Memory entry 'platform' not found"` |
| Permission denied | `"Permission denied for global memory"` |

#### Tier Permission Summary

| Tier | Read | Write/Delete |
|---|---|---|
| `global` | `assistant` permission or superuser | `assistant` permission or superuser |
| `user` | Own entries; superuser can read any | Own entries; superuser can write any |
| `group` | Any member of the group | Members with `assistant` permission on their Member record |

The group is resolved from the request context. If the user is not in a group context, the `group` tier is not accessible via these endpoints.

---

## 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, browse web URLs, describe and query any MojoModel |
| `view_groups` | Query groups, group detail, group members, group activity |
| `view_logs` | Query the audit log trail (logit.Log) — request history, model changes, API errors, custom events |
| `assistant` | Read, write, and delete memory entries across all tiers (subject to per-tier access rules) |

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.

## Models Domain

Users with `view_admin` can ask the assistant to introspect and query any MojoModel directly. Two tools support this:

### `describe_model`

Returns a model's fields, available graphs, permissions, and search fields. Useful for discovery before querying.

**Required permission**: `view_admin`

Sensitive fields (`password`, `auth_key`, `onetime_code`, `secret`, `token_secret`) are never included in the output. Only models with a `RestMeta` definition are available.

**Example response shape**:

```json
{
    "model": "account.User",
    "fields": [
        {"name": "id", "type": "integer", "nullable": false},
        {"name": "email", "type": "email", "nullable": false},
        {"name": "is_active", "type": "boolean", "nullable": false}
    ],
    "graphs": {
        "default": ["id", "email", "username", "is_active", "created"],
        "detail": ["id", "email", "username", "first_name", "last_name", "is_active", "created"]
    },
    "permissions": {
        "view": ["view_admin"],
        "save": ["manage_users"]
    },
    "search_fields": ["email", "username", "first_name", "last_name"]
}
```

### `query_model`

Query a MojoModel with filters, search, ordering, and format options. Respects the same `RestMeta` permissions and owner/group scoping as the REST API — a user can only see records they are normally allowed to see.

**Required permission**: `view_admin` plus any permissions the model's `RestMeta` `VIEW_PERMS` requires.

**Supports**:
- ORM filter expressions (e.g. `{"status": "active", "created__gte": "2026-01-01"}`)
- Free-text search via the model's `SEARCH_FIELDS`
- Ordering (prefix field with `-` for descending)
- JSON output (default) or CSV export
- Count-only mode — returns total without row data
- Configurable limit (default 50, max 200)

Filtering on sensitive fields is blocked and logged as a security event.

**Example response shape (JSON)**:

```json
{
    "model": "incident.Incident",
    "results": [
        {"id": 1, "status": "open", "priority": 10, "category": "brute_force", "created": "2026-04-01T10:00:00Z"}
    ],
    "count": 1,
    "total": 47
}
```

**Example response shape (count only)**:

```json
{
    "model": "incident.Incident",
    "count": 47
}
```

## Logs Domain

Users with `view_logs` can ask the assistant to search and analyze the audit log trail. Every HTTP request/response, model change, API error, and custom event is recorded in `logit.Log`.

### `query_logs`

**Required permission**: `view_logs`

Query the audit trail with filters. Always time-bounded — defaults to the last 60 minutes, maximum 7 days.

**Filters available**: time range, log level, kind, model name/ID, user ID, IP address, request path, HTTP method, and free-text search in log content.

**Example queries**:

```
"Show me all errors in the last hour"
"What did user 42 do today?"
"Were there any failed login attempts from 1.2.3.4?"
"Show me every change made to incident 99"
"How many API errors occurred in the last 24 hours?"
```

**Example response shape**:

```json
{
    "results": [
        {
            "id": 12345,
            "created": "2026-04-05 14:23:11+00:00",
            "level": "error",
            "kind": "api_error",
            "method": "POST",
            "path": "/api/account/login",
            "ip": "1.2.3.4",
            "uid": null,
            "username": null,
            "model_name": null,
            "model_id": null,
            "log": "Invalid credentials"
        }
    ],
    "count": 1,
    "total": 47,
    "period_minutes": 60
}
```

In verbose mode (`verbose: true`), each result also includes `log` (full content, not truncated), `payload`, and `user_agent`. By default, log content is truncated at 500 characters and `log_truncated: true` is added to indicate trimming occurred.

**Count-only mode** returns just the total:

```json
{
    "count": 47,
    "period_minutes": 60
}
```

## 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;
        }
    }
}
```
