# 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.

---

## Tool Loading

The assistant uses a two-tier system to manage which tools are active in each conversation.

**Core tools** are always available from the first message: memory, models, docs, web, logs, and file tools. These handle general queries.

**Domain tools** (security, jobs, users, groups, metrics) are loaded on demand. When the assistant needs domain-specific capabilities, it calls the built-in `load_tools` tool first. You will see this reflected in `tool_calls_made`:

```json
"tool_calls_made": [
    {"tool": "load_tools", "input": {"domain": "jobs"}},
    {"tool": "query_jobs", "input": {"status": "failed", "minutes": 60}}
]
```

Domain tools persist for the rest of the conversation — the assistant will not call `load_tools` again for the same domain in the same conversation.

**From your perspective as a UI developer:** No changes are needed. The `tool_calls_made` array may include a `load_tools` call at the start of domain-specific queries. You can display this as a status indicator (e.g., "Loading job tools...") or ignore it entirely.

## 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 scheduled tasks |
| `manage_jobs` | All `view_jobs` tools + cancel job, retry job, create/update/delete scheduled tasks, run job, run scheduled task now |
| `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, load domain tools |
| `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 and skills 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 confirms with the user before executing these using **action blocks** (see [Action Blocks](#action-blocks)).

The typical flow:

1. User: "Block IP 1.2.3.4"
2. Assistant sends an `action` block: "Block IP 1.2.3.4 for 24 hours?" with **Confirm** / **Cancel** buttons
3. User clicks **Confirm** — client sends `assistant_action` message with `value: "confirm"`
4. Assistant: "Done. IP 1.2.3.4 has been blocked for 86400 seconds."

See [Action Block Interaction Flow](#action-block-interaction-flow) for the full client-side wiring.

## 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.

### Client-to-Server Messages

| Message type | When to send | Key fields |
|---|---|---|
| `assistant_message` | User sends a chat message | `message` (string), `conversation_id` (int, optional) |
| `assistant_action` | User clicks a button in an action block | `value` (string), `conversation_id` (int) |

**`assistant_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
}));
```

**`assistant_action`**:

```javascript
ws.send(JSON.stringify({
    type: 'assistant_action',
    value: 'confirm',         // the action's value field from the action block
    conversation_id: 42,
}));
```

The server converts `assistant_action` into a regular user message containing the chosen value and continues the conversation. No special server-side state is needed.

### Server-to-Client 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_plan` | After the assistant creates a task plan | `{conversation_id, plan}` |
| `assistant_plan_update` | After each plan step status changes | `{conversation_id, plan_id, step_id, status, summary}` |
| `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. `null` when none present. Always included — 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`.

**`assistant_plan` payload**:

```json
{
    "type": "assistant_plan",
    "conversation_id": 42,
    "plan": {
        "plan_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
        "title": "Security Audit (24h)",
        "steps": [
            {"id": 1, "description": "Check open incidents", "status": "pending", "summary": null, "parallel": true},
            {"id": 2, "description": "Check blocked IPs", "status": "pending", "summary": null, "parallel": true},
            {"id": 3, "description": "Synthesize findings", "status": "pending", "summary": null, "parallel": false}
        ]
    }
}
```

**`assistant_plan_update` payload**:

```json
{
    "type": "assistant_plan_update",
    "conversation_id": 42,
    "plan_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "step_id": 1,
    "status": "done",
    "summary": "14 open incidents"
}
```

Step statuses: `pending`, `in_progress`, `done`, `skipped`.

### 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_plan', (data) => {
    showPlanTracker(data.conversation_id, data.plan);
});

ws.on('assistant_plan_update', (data) => {
    updatePlanStep(data.plan_id, data.step_id, data.status, data.summary);
});

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` or `assistant_action` 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 per tool; if the LLM creates a plan, `assistant_plan` fires once, then `assistant_plan_update` fires per step change
5. When done, `assistant_response` (or `assistant_error`) is published

All events arrive as `{"type": "assistant_*", ...}` directly on the WebSocket. There is no `{"type": "message", "data": ...}` wrapper.

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

### Action Block Interaction Flow

When the assistant responds with an `action` block (see [Action Blocks](#action-blocks)), the user must click one of the provided buttons. Here is the full interaction pattern:

```javascript
function renderBlocks(blocks) {
    for (const block of blocks) {
        if (block.type === 'action') {
            renderActionCard(block);
        }
        // ... other block types
    }
}

function renderActionCard(block) {
    const card = document.createElement('div');
    card.className = 'action-card';
    card.dataset.actionId = block.action_id;

    const title = document.createElement('h4');
    title.textContent = block.title;
    card.appendChild(title);

    if (block.description) {
        const desc = document.createElement('p');
        desc.textContent = block.description;
        card.appendChild(desc);
    }

    for (const action of block.actions) {
        const btn = document.createElement('button');
        btn.textContent = action.label;
        btn.onclick = () => sendAction(block.action_id, action.value, conversationId);
        card.appendChild(btn);
    }

    messagesContainer.appendChild(card);
}

function sendAction(actionId, value, conversationId) {
    // Disable all buttons in the card to prevent double-submit
    const card = document.querySelector(`[data-action-id="${actionId}"]`);
    card.querySelectorAll('button').forEach(btn => btn.disabled = true);

    ws.send(JSON.stringify({
        type: 'assistant_action',
        value: value,
        conversation_id: conversationId,
    }));
}
```

The server receives the `assistant_action`, converts the chosen value into a regular user message, and the conversation continues from there. The `assistant_thinking` event will fire as normal.

## Structured Data Blocks

Responses may include a `blocks` array containing structured data for rendering. 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) |

#### `action`

For mutating operations that need user confirmation. Render as a card with buttons. When the user clicks a button, send an `assistant_action` WebSocket message.

```json
{
    "type": "action",
    "title": "Block IP",
    "description": "Block 1.2.3.4 on all firewall sets for 24 hours",
    "actions": [
        {"label": "Confirm", "value": "confirm"},
        {"label": "Cancel", "value": "cancel"}
    ],
    "action_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
```

| Field | Type | Description |
|---|---|---|
| `title` | string | Card heading |
| `description` | string | Details of the operation |
| `actions` | object[] | Each with `label` (button text) and `value` (sent back to server) |
| `action_id` | string | UUID assigned server-side. Use this to identify which card was interacted with. |

**Rendering guidance**: Disable all buttons on the card immediately when the user clicks one, to prevent double-submission. The server will send `assistant_thinking` then `assistant_response` as normal after receiving the action.

See [Action Block Interaction Flow](#action-block-interaction-flow) in the WebSocket section for the full wiring example.

#### `list`

For single-record key/value summaries. Prefer this over a one-row table for user profiles, incident details, job info, and any single object with multiple fields.

```json
{
    "type": "list",
    "title": "User Detail",
    "items": [
        {"label": "Email", "value": "admin@example.com"},
        {"label": "Role", "value": "Admin"},
        {"label": "Last Login", "value": "2026-04-01 09:30 UTC"}
    ]
}
```

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

**Rendering guidance**: Render as a definition list or two-column card — label in muted text, value in regular weight.

#### `alert`

For permission denials, important warnings, error conditions, and success confirmations that need visual distinction from narrative text. Used sparingly — only for genuinely important information.

```json
{
    "type": "alert",
    "level": "warning",
    "title": "Rate Limited",
    "message": "User exceeded 100 req/min threshold. Current rate: 142 req/min."
}
```

| Field | Type | Description |
|---|---|---|
| `level` | string | `info`, `success`, `warning`, or `error` |
| `title` | string | Alert heading (optional but usually present) |
| `message` | string | Alert body |

**Rendering guidance**: Map `level` to a color/icon: `info` (blue/info icon), `success` (green/checkmark), `warning` (yellow/triangle), `error` (red/X). Render as a banner or callout — distinct from narrative text.

### 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;
            case 'action':
                renderActionCard(block);   // see Action Block Interaction Flow
                break;
            case 'list':
                renderListCard(block.title, block.items);
                break;
            case 'alert':
                renderAlert(block.level, block.title, block.message);
                break;
        }
    }
}
```

## Plan Tracker

When the assistant handles a complex multi-step request, it may create an execution plan. You receive `assistant_plan` once (when the plan is created) and then a stream of `assistant_plan_update` events as each step progresses.

**Rendering guidance**: Render the plan as a vertical list of steps. Each step has a `status` badge (`pending` → grey, `in_progress` → blue/spinner, `done` → green, `skipped` → grey strikethrough). Show `summary` next to a completed step as a brief sub-label.

```javascript
ws.on('assistant_plan', (data) => {
    const { plan_id, title, steps } = data.plan;
    renderPlanTracker(plan_id, title, steps);
});

ws.on('assistant_plan_update', (data) => {
    const { plan_id, step_id, status, summary } = data;
    updatePlanStep(plan_id, step_id, status, summary);
});
```

The plan tracker is informational — no user interaction is needed. The assistant drives all step transitions automatically. When `assistant_response` arrives, the plan is complete.
