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:
- mongoose (single
mongoose.c+mongoose.h) — battle-tested, MIT license, supports polling model - 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
- Add
api_config_tto config and parse it - Vendor mongoose.c/mongoose.h, update Makefile
- Create
src/http_api.cwith init/poll/cleanup skeleton + CORS - Wire into main.c poll loop
- Implement read-only endpoints first:
/api/status,/api/config,/api/relays,/api/model,/api/tools,/api/triggers - Implement Nostr event endpoints:
/api/events/soul,/api/events/skills,/api/events/adoption,/api/events/profile,/api/events/startup - Implement context inspector:
/api/context/parts,/api/context/current,/api/context/log - Implement mutation endpoints: PUT soul, PUT skills, PUT model, PUT profile
- Implement tool execution endpoint: POST
/api/tools/:name/execute - Implement context preview: POST
/api/context/preview - Test all endpoints via curl
- 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.1only — not accessible from network - No authentication — this is a local dev tool
- The
api.enabledconfig flag defaults tofalseso it must be explicitly opted in - Tool execution endpoint gives full admin-tier access — acceptable for localhost dev dashboard
- Config endpoint redacts
nsecandapi_keyfields
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.