Files
didactyl/plans/admin_api.md

14 KiB

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

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

{
  "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

{
  "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.

{
  "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:

{
  "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.

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

Response:

{
  "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.

{
  "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:

{
  "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

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

#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

// 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

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.