# Didactyl Admin HTTP API — Architecture & Implementation Plan

## Overview

Add a localhost-only HTTP API to didactyl so an external web dashboard can inspect and manage the agent at runtime. No authentication required — binding to `127.0.0.1` only. All responses are JSON. CORS headers included for browser access from any local origin.

The web frontend is a separate project; this plan covers only the C-side HTTP server and API endpoints.

---

## Architecture

```mermaid
flowchart LR
    subgraph didactyl process
        MAIN[main loop] --> POLL[nostr_handler_poll]
        MAIN --> TPOLL[trigger_manager_poll]
        MAIN --> HPOLL[http_api_poll]
        HPOLL --> ROUTER[request router]
        ROUTER --> AGENT[agent internals]
        ROUTER --> NOSTR[nostr_handler]
        ROUTER --> TOOLS[tools context]
        ROUTER --> CONFIG[config]
        ROUTER --> TRIGGERS[trigger_manager]
    end
    BROWSER[Web Dashboard] -- HTTP localhost:8484 --> HPOLL
```

### HTTP Library Choice

Use a minimal embedded HTTP server. Two good options for C with no extra dependencies:

1. **mongoose** (single `mongoose.c` + `mongoose.h`) — battle-tested, MIT license, supports polling model
2. **microhttpd** (libmicrohttpd) — GNU project, available as system package

**Recommendation: mongoose** — it is a single-file drop-in, works with the existing poll-based main loop, and requires zero system dependencies. Just add `mongoose.c` and `mongoose.h` to the project.

### Integration Pattern

The HTTP server runs in the same thread as the main poll loop. Each iteration calls `http_api_poll()` which does non-blocking accept/read/write via mongoose's `mg_mgr_poll()`. This avoids threading complexity and gives the API direct access to all agent state.

---

## Config Extension

```json
{
  "api": {
    "enabled": true,
    "port": 8484,
    "bind_address": "127.0.0.1"
  }
}
```

Defaults: enabled=false, port=8484, bind=127.0.0.1.

---

## API Endpoints

All endpoints return JSON. All mutations use POST/PUT/DELETE. All reads use GET.

### Agent Identity & Status

| Method | Path | Description |
|---|---|---|
| GET | `/api/status` | Agent runtime status: pubkey, display name, version, uptime, connected relay count, trigger count |
| GET | `/api/config` | Current runtime config (redacted: nsec/api_key masked) |

### Nostr Events — Read & Edit

| Method | Path | Description |
|---|---|---|
| GET | `/api/events/soul` | Fetch the agent soul event (kind 31120, d=soul) |
| PUT | `/api/events/soul` | Update soul content, republish to relays |
| GET | `/api/events/skills` | List all published skills (kind 31123/31124 by own pubkey) |
| GET | `/api/events/skills/:d_tag` | Fetch a single skill by d_tag |
| PUT | `/api/events/skills/:d_tag` | Update skill content/tags, republish |
| DELETE | `/api/events/skills/:d_tag` | Remove skill from adoption list |
| GET | `/api/events/adoption` | Fetch kind 10123 adoption list |
| GET | `/api/events/startup` | List startup events from config |
| GET | `/api/events/profile` | Fetch agent kind 0 profile |
| PUT | `/api/events/profile` | Update agent kind 0 profile, republish |
| GET | `/api/events/query` | Generic Nostr query — pass filter as query params or JSON body |

### Context Inspector

| Method | Path | Description |
|---|---|---|
| GET | `/api/context/current` | Build and return the full context that would be sent to the LLM right now, broken into labeled parts |
| GET | `/api/context/parts` | Return context parts with individual sizes (bytes and estimated tokens) |
| GET | `/api/context/log` | Return recent context.log entries (last N blocks, configurable via ?limit=) |
| POST | `/api/context/preview` | Accept a modified context structure, return what the LLM payload would look like (dry run, no send) |

### Context Parts Response Shape

```json
{
  "total_chars": 12450,
  "total_estimated_tokens": 3112,
  "parts": [
    {
      "name": "system_prompt",
      "role": "system",
      "chars": 1200,
      "estimated_tokens": 300,
      "content": "# Didactyl Agent..."
    },
    {
      "name": "admin_identity",
      "role": "system",
      "chars": 450,
      "estimated_tokens": 112,
      "content": "This is your administrator!..."
    },
    {
      "name": "admin_kind0",
      "role": "system",
      "chars": 320,
      "estimated_tokens": 80,
      "content": "Administrator kind 0 profile..."
    },
    {
      "name": "startup_events",
      "role": "system",
      "chars": 4800,
      "estimated_tokens": 1200,
      "content": "Startup events memory..."
    },
    {
      "name": "adopted_skills",
      "role": "system",
      "chars": 2100,
      "estimated_tokens": 525,
      "content": "Adopted skills memory..."
    },
    {
      "name": "dm_history",
      "role": "mixed",
      "chars": 2400,
      "estimated_tokens": 600,
      "turns": 8
    },
    {
      "name": "admin_notes",
      "role": "system",
      "chars": 680,
      "estimated_tokens": 170,
      "content": "Administrator recent public notes..."
    },
    {
      "name": "tools_schema",
      "chars": 500,
      "estimated_tokens": 125,
      "tool_count": 28
    }
  ]
}
```

### Triggers

| Method | Path | Description |
|---|---|---|
| GET | `/api/triggers` | List active triggers with status (wraps existing trigger_manager_status_json) |

### Model / LLM

| Method | Path | Description |
|---|---|---|
| GET | `/api/model` | Current model config (wraps existing model_get) |
| PUT | `/api/model` | Update model config (wraps existing model_set) |
| GET | `/api/models` | List available models from provider (wraps existing model_list) |

### Relays

| Method | Path | Description |
|---|---|---|
| GET | `/api/relays` | Relay connection status (wraps existing relay_status tool) |

### Prompt Crafting & Execution

| Method | Path | Description |
|---|---|---|
| POST | `/api/prompt/run` | Submit a custom messages array with tools enabled; returns full LLM response including tool calls and results |
| POST | `/api/prompt/run-simple` | Submit system prompt + user message; returns LLM text response (no tools) |
| POST | `/api/prompt/compare` | A/B test: submit two prompt variants, run both, return side-by-side responses |

#### POST /api/prompt/run

Send a fully crafted messages array to the LLM with the full tool set enabled. The agent executes tool calls and returns the complete conversation.

```json
{
  "messages": [
    {"role": "system", "content": "You are Didactyl..."},
    {"role": "system", "content": "Adopted skills memory..."},
    {"role": "user", "content": "Tweet about the weather"}
  ],
  "model": "claude-haiku-4.5",
  "max_turns": 5,
  "tools_enabled": true
}
```

Response:

```json
{
  "success": true,
  "final_response": "Done! I posted a tweet about the weather.",
  "turns": [
    {
      "turn": 1,
      "tool_calls": [
        {"name": "nostr_post", "arguments": "...", "result": "..."}
      ]
    }
  ],
  "model_used": "claude-haiku-4.5",
  "total_input_tokens_estimate": 3200,
  "total_output_tokens_estimate": 180
}
```

#### POST /api/prompt/run-simple

Quick iteration on prompt wording without tools.

```json
{
  "system": "You are a helpful assistant that writes tweets...",
  "user": "Write a tweet about AI agents on Nostr",
  "model": "claude-haiku-4.5"
}
```

Response:

```json
{
  "success": true,
  "response": "AI agents are finding their home on Nostr...",
  "model_used": "claude-haiku-4.5",
  "input_tokens_estimate": 85,
  "output_tokens_estimate": 42
}
```

#### POST /api/prompt/compare

A/B testing: submit two prompt variants, both are executed, responses returned side-by-side.

```json
{
  "variant_a": {
    "messages": [
      {"role": "system", "content": "You are Didactyl. Keep responses under 280 chars."},
      {"role": "user", "content": "Tweet about your new skill"}
    ],
    "model": "claude-haiku-4.5",
    "tools_enabled": true
  },
  "variant_b": {
    "messages": [
      {"role": "system", "content": "You are Didactyl. Be concise. No markdown. No emoji."},
      {"role": "user", "content": "Tweet about your new skill"}
    ],
    "model": "claude-haiku-4.5",
    "tools_enabled": true
  }
}
```

Response:

```json
{
  "success": true,
  "variant_a": {
    "final_response": "Just picked up the tweet-composer skill! ...",
    "turns": [],
    "model_used": "claude-haiku-4.5",
    "total_input_tokens_estimate": 3200,
    "total_output_tokens_estimate": 95
  },
  "variant_b": {
    "final_response": "New skill acquired: tweet-composer. ...",
    "turns": [],
    "model_used": "claude-haiku-4.5",
    "total_input_tokens_estimate": 3100,
    "total_output_tokens_estimate": 78
  }
}
```

The compare endpoint runs variant_a first, then variant_b sequentially. Each variant can optionally use a different model for cross-model comparison.

#### Prompt Crafting Workflow

```mermaid
flowchart TD
    LOAD[GET /api/context/parts] --> EDIT[Edit parts in UI]
    EDIT --> PREVIEW[POST /api/context/preview]
    PREVIEW --> FIRE[POST /api/prompt/run]
    FIRE --> COMPARE{Want to compare?}
    COMPARE -- Yes --> AB[POST /api/prompt/compare]
    COMPARE -- No --> PERSIST{Like the result?}
    AB --> PERSIST
    PERSIST -- Yes --> SAVE[PUT /api/events/soul or skills]
    PERSIST -- No --> EDIT
```

### Tools

| Method | Path | Description |
|---|---|---|
| GET | `/api/tools` | List all registered tool schemas |
| POST | `/api/tools/:name/execute` | Execute a tool by name with JSON body as args (admin-only equivalent) |

---

## Implementation Plan

### New Files

| File | Purpose |
|---|---|
| `src/http_api.c` | HTTP server, request router, endpoint handlers |
| `src/http_api.h` | Public API: init, poll, cleanup |
| `vendor/mongoose.c` | Mongoose HTTP library (single file) |
| `vendor/mongoose.h` | Mongoose header |

### Modified Files

| File | Change |
|---|---|
| `src/config.h` | Add `api_config_t` struct to `didactyl_config_t` |
| `src/config.c` | Parse `api` config section |
| `src/main.c` | Call `http_api_init()`, add `http_api_poll()` to main loop, call `http_api_cleanup()` on shutdown |
| `src/agent.h` | Expose `agent_build_context_parts_json()` for context inspector |
| `src/agent.c` | Implement `agent_build_context_parts_json()` that builds context and returns labeled parts with sizes |
| `Makefile` | Add `vendor/mongoose.c` and `src/http_api.c` to SRCS, add `-Ivendor` to INCLUDES |

### http_api.h

```c
#ifndef DIDACTYL_HTTP_API_H
#define DIDACTYL_HTTP_API_H

#include "config.h"
#include "tools.h"

struct trigger_manager;

typedef struct {
    didactyl_config_t* cfg;
    tools_context_t* tools_ctx;
    struct trigger_manager* trigger_manager;
} http_api_context_t;

int http_api_init(http_api_context_t* ctx);
int http_api_poll(int timeout_ms);
void http_api_cleanup(void);

#endif
```

### Main Loop Integration

```c
// In main.c, after agent_init and trigger_manager_init:
http_api_context_t api_ctx = {
    .cfg = &cfg,
    .tools_ctx = &g_tools_ctx,  // need to expose from agent
    .trigger_manager = &trigger_manager
};

if (cfg.api.enabled) {
    if (http_api_init(&api_ctx) != 0) {
        DEBUG_WARN("HTTP API failed to start");
    }
}

// In main loop:
while (g_running) {
    nostr_handler_poll(100);
    trigger_manager_poll(&trigger_manager);
    if (cfg.api.enabled) {
        http_api_poll(0);  // non-blocking
    }
    nanosleep(...);
}

// On shutdown:
if (cfg.api.enabled) {
    http_api_cleanup();
}
```

### Request Router Pattern

```c
static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
    if (ev == MG_EV_HTTP_MSG) {
        struct mg_http_message* hm = ev_data;

        // Add CORS headers to all responses
        // Route by method + path prefix

        if (mg_match(hm->uri, mg_str("/api/status"), NULL) && is_get(hm)) {
            handle_status(c, hm);
        } else if (mg_match(hm->uri, mg_str("/api/context/parts"), NULL) && is_get(hm)) {
            handle_context_parts(c, hm);
        } else if (mg_match(hm->uri, mg_str("/api/events/skills/*"), NULL)) {
            handle_skill_by_slug(c, hm);
        }
        // ... etc
    }
}
```

---

## Implementation Order

1. Add `api_config_t` to config and parse it
2. Vendor mongoose.c/mongoose.h, update Makefile
3. Create `src/http_api.c` with init/poll/cleanup skeleton + CORS
4. Wire into main.c poll loop
5. Implement read-only endpoints first: `/api/status`, `/api/config`, `/api/relays`, `/api/model`, `/api/tools`, `/api/triggers`
6. Implement Nostr event endpoints: `/api/events/soul`, `/api/events/skills`, `/api/events/adoption`, `/api/events/profile`, `/api/events/startup`
7. Implement context inspector: `/api/context/parts`, `/api/context/current`, `/api/context/log`
8. Implement mutation endpoints: PUT soul, PUT skills, PUT model, PUT profile
9. Implement tool execution endpoint: POST `/api/tools/:name/execute`
10. Implement context preview: POST `/api/context/preview`
11. Test all endpoints via curl
12. Update documentation

---

## CORS Headers

Every response includes:

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type
```

OPTIONS requests return 204 with these headers (preflight support).

---

## Security Notes

- Binds to `127.0.0.1` only — not accessible from network
- No authentication — this is a local dev tool
- The `api.enabled` config flag defaults to `false` so it must be explicitly opted in
- Tool execution endpoint gives full admin-tier access — acceptable for localhost dev dashboard
- Config endpoint redacts `nsec` and `api_key` fields

---

## Token Estimation

For the context size display, use a simple heuristic: `estimated_tokens = chars / 4`. This is a rough approximation that works well enough for English text with the major model families. No need for a real tokenizer.
