# `src/api_hooks.py` & `src/api_hook_client.py` — Hook API

[Top](../Readme.md) | [Architecture](guide_architecture.md) | [Testing](guide_testing.md)

---

## Overview

The Hook API is the **bridge between external automation and the running app**. It exposes Manual Slop's internal state over HTTP on `127.0.0.1:8999`, allowing tests, scripts, and external agents to drive the GUI without direct Python access.

Two files:
- **`src/api_hooks.py`** (~38KB) — the server side: `HookServer`, `ApiHookClient` (the server-side helper class)
- **`src/api_hook_client.py`** (~31KB) — the client side: `ApiHookClient` (the Python client wrapper)

---

## Architecture

```
┌─────────────────────────────────────────────────┐
│  Manual Slop (sloppy.py)                          │
│  - api_hooks.HookServer started in background     │
│  - Listens on http://127.0.0.1:8999              │
└─────────────────┬───────────────────────────────┘
                  │ serves
                  ▼
┌─────────────────────────────────────────────────┐
│  HTTP Endpoints                                   │
│  GET  /status                          - health  │
│  GET  /api/gui/mma_status                   - MMA      │
│  GET  /api/gui                          - GUI      │
│  POST /api/gui                          - push task │
│  POST /api/ask                          - blocking │
│       (synchronous HITL via HTTP)                 │
│  ... etc                                          │
└─────────────────┬───────────────────────────────┘
                  │ consumed by
                  ▼
┌─────────────────────────────────────────────────┐
│  src/api_hook_client.py: ApiHookClient           │
│  Python wrapper. Used by:                        │
│  - live_gui test fixture (251 test files)         │
│  - External scripts and agents                    │
│  - MMA conftest tooling                           │
└─────────────────────────────────────────────────┘
```

The `/api/ask` endpoint is special — it implements the **Remote Confirmation Protocol** for HITL approvals from external contexts. When the AI wants to run a script, the GUI can call `/api/ask` on a remote agent to get a yes/no answer.

---

## The Server: `HookServer` (in `src/api_hooks.py`)

### Lifecycle

The server is started by `app_controller.py` (or `sloppy.py` when `--enable-test-hooks` is passed):

```python
# In app_controller.py or sloppy.py
from src.api_hooks import HookServer
server = HookServer()
server.start()  # Background thread, non-blocking
```

The server runs in a daemon thread. It stops when the process exits (or via `server.stop()`).

### Endpoints

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/status` | Health check. Returns JSON with `status`, `pid`, `version`, etc. |
| `GET` | `/api/gui/mma_status` | Full MMA orchestration state (tickets, progress, ETA) |
| `GET` | `/api/gui` | GUI debug info (button IDs, fields, etc.) |
| `POST` | `/api/gui` | Push a GUI task (click, set_value, custom_callback, etc.) |
| `POST` | `/api/ask` | Synchronous HITL approval (blocking request/response) |
| `GET` | `/api/performance` | Performance metrics (FPS, frame time) |
| `GET` | `/api/comms` | Communication log |
| `GET` | `/api/diagnostics` | Diagnostics state |
| `GET` | `/api/warmup_status` | Warmup progress snapshot: `{pending, completed, failed}` module lists |
| `GET` | `/api/warmup_wait?timeout=N` | Server-side blocking wait for warmup completion (up to N seconds) |
| `GET` | `/api/warmup_canaries` | Per-module import timing records (canary_id, module, thread, elapsed_ms, status) |
| `GET` | `/api/startup_timeline` | Startup phase breakdown: init_start_ts, warmup_done_ts, first_frame_ts, warmup_ms, first_frame_after_init_ms, first_frame_after_warmup_ms |

### Warmup API

The 4 warmup endpoints (added in `startup_speedup_20260606`) let external clients (test harnesses, scripts, the Command Palette's "Restart" actions) answer two questions:
1. **Is the app ready?** — `get_warmup_status()` returns the current `{pending, completed, failed}` module lists. `is_warmup_done()` (via `wait_for_warmup`) blocks until all are done.
2. **Did the warmup block the first frame?** — `get_startup_timeline()` returns the 3 phase breakdowns (AppController init, GUI bundle setup, first render) plus the critical gap between warmup completion and first frame paint.

**Client methods** in `ApiHookClient` (`src/api_hook_client.py:312-348`):

| Method | Endpoint | Purpose |
|---|---|---|
| `get_warmup_status()` | `GET /api/warmup_status` | Returns `{pending, completed, failed}` |
| `get_warmup_wait(timeout=30.0)` | `GET /api/warmup_wait?timeout=N` | Server-side blocking wait |
| `get_warmup_canaries()` | `GET /api/warmup_canaries` | Per-module import timing |
| `get_startup_timeline()` | `GET /api/startup_timeline` | Phase breakdown dict |

**External script pattern:** A test or script that needs the app fully ready should call `client.get_warmup_wait(timeout=60)` before any other API call. This replaces the old `time.sleep(N)` race-condition pattern. See `tests/test_api_hooks_warmup.py` for usage examples.

### Request/Response Format

All endpoints accept and return JSON:

```json
// POST /api/gui
{
    "action": "click",
    "item": "btn_reset"
}

// Response
{
    "status": "queued"
}
```

For blocking endpoints (`/api/ask`):

```json
// POST /api/ask
{
    "prompt": "Allow the AI to run 'rm -rf /'?",
    "timeout": 30
}

// Response (blocks until human responds)
{
    "approved": true,
    "response": "yes go ahead"
}
```

### The `predefined_callbacks` Mechanism

The server supports invoking any App method via the `custom_callback` action:

```python
# Server side (in gui_2.py __init__)
self.controller._predefined_callbacks['_my_method'] = self._my_method
self.controller._gettable_fields['show_my_thing'] = 'show_my_thing'
```

```python
# Client side
client.push_event("custom_callback", {
    "callback": "_my_method",
    "args": [arg1, arg2],
})
```

This is the standard way to expose new App methods to the Hook API.

---

## The Client: `ApiHookClient` (in `src/api_hook_client.py`)

### Connection

```python
from src.api_hook_client import ApiHookClient
client = ApiHookClient()  # connects to http://127.0.0.1:8999 by default
```

Custom host/port:
```python
client = ApiHookClient(host="192.168.1.100", port=8999)
```

### Key Methods

#### `click(item, user_data=None)`

Simulates a button click. The button must be registered as a clickable widget in the GUI.

```python
client.click("btn_reset")
client.click("btn_save_project", user_data={"path": "/path/to/file"})
```

#### `set_value(item, value)`

Sets a widget's value (e.g., a text input).

```python
client.set_value("ui_ai_input", "Hello world")
client.set_value("provider_select", "anthropic")
```

#### `select_tab(item, value)`

Selects a tab in a tab bar.

```python
client.select_tab("main_tabs", "AI Settings")
```

#### `select_list_item(item, value)`

Selects an item in a listbox or combo.

```python
client.select_list_item("model_select", "gemini-3-flash-preview")
```

#### `push_event(action, payload)`

Generic GUI task push. The action is dispatched to the appropriate handler.

```python
client.push_event("custom_callback", {
    "callback": "_my_method",
    "args": [arg1, arg2],
})
```

#### `get_value(item)`

Reads a gettable field's current value.

```python
value = client.get_value("show_command_palette")  # Returns bool
value = client.get_value("current_provider")        # Returns str
```

#### `get_status()`

Calls `/status`. Returns the parsed JSON.

```python
status = client.get_status()
assert status["status"] == "running"
```

#### `reset_session()`

Calls the reset endpoint. Clears the GUI state for a fresh test.

```python
client.reset_session()
```

#### `wait_for_event(event_type, timeout=10)`

Polls the event queue for a specific event type. Useful for waiting for async AI responses.

```python
event = client.wait_for_event("ai_response", timeout=30)
assert event is not None
```

### Robustness

The client has:
- **Retry logic** for transient connection failures
- **Health check polling** — auto-waits for the server to be ready
- **Timeout configuration** per call

---

## The `/api/ask` Protocol (Remote Confirmation)

The `/api/ask` endpoint implements **non-blocking, ID-based challenge/response** for HITL approvals from external contexts.

### Use Case

A headless service (running in a container, e.g., on Unraid) needs to ask the human "should the AI run this command?" The human is monitoring via a web client. The flow:

```
1. Headless service: POST /api/ask with prompt
2. Server: creates a challenge with unique ID
3. Server: pushes challenge to GUI's pending-ask queue
4. GUI: renders ask modal (or queues for later)
5. Human: clicks Approve or Reject in GUI
6. Server: unblocks the /api/ask call with the answer
7. Headless service: receives the response
```

This decouples the headless service from the GUI's render loop. The headless service doesn't need a direct connection to the GUI process.

---

## Connection to the GUI

The `App` class has a few integration points with the Hook API:

### In `gui_2.py` `__init__`

```python
# Register predefined callbacks
self.controller._predefined_callbacks['save_context_preset'] = self.save_context_preset
self.controller._predefined_callbacks['load_context_preset'] = self.load_context_preset
self.controller._predefined_callbacks['_toggle_command_palette'] = self._toggle_command_palette
# ... etc

# Register gettable fields
self.controller._gettable_fields['show_command_palette'] = 'show_command_palette'
```

### In `app_controller.py`

`AppController._init_ai_and_hooks()` starts the `HookServer` if `--enable-test-hooks` is set.

### In `sloppy.py`

Parses `--enable-test-hooks` from CLI args and passes it to the controller.

---

## Common Patterns

### Test Pattern (from `live_gui` tests)

```python
def test_my_feature(live_gui):
    client = ApiHookClient()
    assert client.wait_for_server(timeout=10)

    # Open the command palette
    client.push_event("custom_callback", {
        "callback": "_toggle_command_palette",
        "args": [],
    })
    time.sleep(0.5)

    # Verify state
    assert client.get_value("show_command_palette") is True

    # Close
    client.push_event("custom_callback", {
        "callback": "_toggle_command_palette",
        "args": [],
    })
```

### External Script Pattern

```python
import requests
import time

def wait_for_app(url="http://127.0.0.1:8999", timeout=30):
    start = time.time()
    while time.time() - start < timeout:
        try:
            r = requests.get(f"{url}/status", timeout=1)
            if r.status_code == 200:
                return r.json()
        except requests.ConnectionError:
            pass
        time.sleep(0.5)
    raise RuntimeError("App did not start in time")

def trigger_hot_reload(url="http://127.0.0.1:8999"):
    r = requests.post(f"{url}/api/gui", json={"action": "click", "item": "btn_hot_reload"})
    r.raise_for_status()
    return r.json()
```

### Synchronous Ask Pattern

```python
def ask_human_approval(prompt, url="http://127.0.0.1:8999", timeout=60):
    r = requests.post(
        f"{url}/api/ask",
        json={"prompt": prompt, "timeout": timeout},
        timeout=timeout + 5,
    )
    r.raise_for_status()
    data = r.json()
    return data.get("approved", False), data.get("response", "")
```

---

## Testing

### Unit Tests (`tests/test_api_hook_client.py`)

Tests the `ApiHookClient` class methods in isolation (mocked HTTP).

### Live GUI Tests

Use the `live_gui` fixture which auto-launches `sloppy.py --enable-test-hooks`. The `ApiHookClient` then connects to the running app.

```python
def test_via_hooks(live_gui):
    client = ApiHookClient()
    # ... use the client ...
```

### Headless Service Tests

`tests/test_headless_service.py` and `tests/test_headless_verification.py` test the full server lifecycle without launching the GUI.

---

## Limitations

1. **Single GUI instance per port**: The server binds to a specific port. Multiple instances on the same port fail.
2. **No authentication**: The Hook API has no auth. It's intended for localhost use. For remote access, use a reverse proxy with auth.
3. **Synchronous `/api/ask` blocks the server thread**: Long timeouts can starve other requests. Use reasonable timeouts.
4. **JSON-only payloads**: All endpoints take and return JSON. Binary data is not supported.

---

## See Also

- **[guide_architecture.md](guide_architecture.md#the-task-pipeline-producer-consumer-synchronization)** — Threading model
- **[guide_testing.md](guide_testing.md#the-hook-api-for-integration-tests)** — How tests use this API
- **[guide_meta_boundary.md](guide_meta_boundary.md)** — Application vs Meta-Tooling domain separation
- **`tests/conftest.py:live_gui`** — The session fixture that uses this API
- **`tests/test_api_hook_client.py`** — Client unit tests
- **`tests/test_hooks.py`** — Basic HookServer smoke tests
