12 KiB
Didactyl — MVP Architecture Plan
Vision
Didactyl is a sovereign AI agent daemon written in C99 that uses Nostr as its sole communication and memory layer. The agent boots on any internet-connected machine, connects to Nostr relays, listens for encrypted commands from its administrator, forwards them to an LLM, and replies via encrypted Nostr messages.
Because all identity, communication, and memory live on Nostr, the agent is portable (start it anywhere) and sovereign (no single entity can erase its memory).
MVP Scope — Proof of Concept
The first version does exactly this:
- Read config from
config.json(keys, relays, LLM settings, profile metadata) - Read agent context/personality from
SYSTEM.md - Connect to configured relays and stay connected (relay pool with auto-reconnect)
- Publish a kind 0 metadata event (agent profile)
- Subscribe to kind 4 NIP-04 encrypted DMs addressed to the agent
- When a DM arrives from the authorized admin pubkey:
- Decrypt it
- Send the message + SYSTEM.md context to the configured LLM API
- Get the LLM response
- Encrypt and send the response back as a kind 4 DM to the admin
- Loop forever (daemon)
Architecture
flowchart TD
A[config.json] -->|keys, relays, LLM config| D[didactyl daemon]
B[SYSTEM.md] -->|agent context/personality| D
D -->|1. Connect| R[Nostr Relay Pool]
D -->|2. Publish kind 0| R
D -->|3. Subscribe kind 4 + p-tag filter| R
R -->|4. Incoming encrypted DM| D
D -->|5. Decrypt with NIP-04| V{From admin pubkey?}
V -->|No| X[Ignore]
V -->|Yes| L[LLM API via HTTPS]
L -->|Response| D
D -->|6. Encrypt response with NIP-04| R
R -->|7. Deliver kind 4 DM to admin| ADM[Admin Nostr Client]
File Structure
didactyl/
├── config.json # Agent configuration
├── SYSTEM.md # Agent context/personality for LLM
├── src/
│ ├── main.c # Entry point, daemon loop
│ ├── config.c # JSON config parsing
│ ├── config.h # Config structures and API
│ ├── agent.c # Core agent logic: receive → LLM → respond
│ ├── agent.h # Agent API
│ ├── llm.c # LLM HTTP API client (simple HTTPS POST)
│ ├── llm.h # LLM API
│ ├── nostr_handler.c # Nostr relay pool, subscriptions, publish
│ ├── nostr_handler.h # Nostr handler API
│ └── context.c # Load SYSTEM.md into memory
│ └── context.h # Context API
├── Makefile # Build system
└── README.md # Project documentation
Configuration — config.json
{
"agent": {
"name": "Didactyl Agent",
"display_name": "Didactyl",
"about": "A sovereign AI agent on Nostr",
"picture": "",
"nip05": ""
},
"keys": {
"nsec": "nsec1..."
},
"admin": {
"pubkey": "hex-pubkey-of-admin"
},
"relays": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.laantungir.net"
],
"llm": {
"provider": "openai",
"api_key": "sk-...",
"model": "gpt-4o-mini",
"base_url": "https://api.openai.com/v1",
"max_tokens": 2048,
"temperature": 0.7
}
}
Agent Context — SYSTEM.md
Recommended name: SYSTEM.md — this is the universal term LLMs understand as their foundational instructions. Every major LLM API uses "system" as the role for context/personality instructions.
Example content:
# Didactyl Agent
You are Didactyl, a sovereign AI agent that lives on the Nostr protocol.
## Personality
- You are helpful, concise, and technically knowledgeable
- You communicate via Nostr encrypted DMs
## Capabilities
- You can answer questions and have conversations
- Your memory persists on Nostr relays
## Rules
- Always be honest about your capabilities
- Keep responses concise for the DM format
Component Details
1. main.c — Entry Point
Responsibilities:
- Parse CLI arguments (config path, debug level, context file path)
- Call
config_load()to parseconfig.json - Call
context_load()to readSYSTEM.mdinto memory - Call
nostr_init()to initialize the crypto subsystem - Decode the nsec from config into a 32-byte private key
- Derive the public key
- Call
nostr_handler_init()to set up relay pool and connect - Publish kind 0 metadata event
- Subscribe to kind 4 events with a
#ptag filter for the agent's pubkey - Enter the main daemon loop:
nostr_relay_pool_poll()in a loop - Handle SIGINT/SIGTERM for clean shutdown
- Call cleanup functions on exit
2. config.c / config.h — Configuration
Structures:
typedef struct {
char name[128];
char display_name[128];
char about[512];
char picture[256];
char nip05[256];
} agent_profile_t;
typedef struct {
char nsec[100];
unsigned char private_key[32];
unsigned char public_key[32];
char public_key_hex[65];
} agent_keys_t;
typedef struct {
char pubkey[65]; // hex
} admin_config_t;
typedef struct {
char provider[32];
char api_key[256];
char model[64];
char base_url[256];
int max_tokens;
double temperature;
} llm_config_t;
typedef struct {
agent_profile_t profile;
agent_keys_t keys;
admin_config_t admin;
char** relays;
int relay_count;
llm_config_t llm;
} didactyl_config_t;
Functions:
int config_load(const char* path, didactyl_config_t* config)— Parse JSON, populate structvoid config_free(didactyl_config_t* config)— Free allocated memory
Uses cJSON (already bundled in nostr_core_lib) for parsing.
3. context.c / context.h — System Context
Simple file reader:
char* context_load(const char* path)— Read entire file into malloc'd stringvoid context_free(char* context)— Free the string
4. nostr_handler.c / nostr_handler.h — Nostr Communication
This wraps the nostr_core_lib relay pool API:
Functions:
int nostr_handler_init(didactyl_config_t* config)— Create relay pool, add relays, connectint nostr_handler_publish_profile(didactyl_config_t* config)— Create and publish kind 0 eventint nostr_handler_subscribe_dms(didactyl_config_t* config, dm_callback_t callback, void* user_data)— Subscribe to kind 4 events tagged with agent's pubkeyint nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message, didactyl_config_t* config)— Encrypt and publish a kind 4 DMint nostr_handler_poll(int timeout_ms)— Drive the event loopvoid nostr_handler_cleanup(void)— Destroy pool and cleanup
The DM subscription callback:
typedef void (*dm_callback_t)(const char* sender_pubkey, const char* decrypted_message, void* user_data);
When a kind 4 event arrives:
- Extract the sender pubkey from the event
- Check if sender matches
config.admin.pubkey— if not, ignore - Decrypt the content using
nostr_nip04_decrypt() - Call the registered callback with the decrypted message
5. agent.c / agent.h — Agent Logic
The brain that ties everything together:
Functions:
int agent_init(didactyl_config_t* config, const char* system_context)— Store config and contextvoid agent_on_message(const char* sender_pubkey, const char* message, void* user_data)— The DM callback implementation:- Log the incoming message
- Call
llm_chat()with the system context + user message - Send the LLM response back via
nostr_handler_send_dm()
void agent_cleanup(void)— Cleanup
6. llm.c / llm.h — LLM API Client
A minimal HTTP client for the OpenAI-compatible chat completions API:
Functions:
int llm_init(llm_config_t* config)— Store configchar* llm_chat(const char* system_prompt, const char* user_message)— Send chat completion request, return response string (caller frees)void llm_cleanup(void)— Cleanup
Implementation approach:
- Use
libcurlfor HTTPS requests (widely available, simple C API) - Build the JSON request body with
cJSON:{ "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": "<SYSTEM.md content>"}, {"role": "user", "content": "<decrypted DM message>"} ], "max_tokens": 2048, "temperature": 0.7 } - Parse the JSON response with
cJSONto extractchoices[0].message.content
Dependencies
| Dependency | Purpose | Source |
|---|---|---|
nostr_core_lib |
Nostr protocol: keys, events, NIP-04, relay pool | In workspace |
c_utils_lib |
Debug logging | In workspace |
cJSON |
JSON parsing | Bundled in nostr_core_lib |
libcurl |
HTTPS for LLM API calls | System package |
libssl/libcrypto |
TLS for WebSocket connections | System package (already required by nostr_core_lib) |
Build System — Makefile
CC = gcc
CFLAGS = -std=c99 -Wall -Wextra -pedantic -D_POSIX_C_SOURCE=200809L
LIBS = -lcurl -lssl -lcrypto -lm -lpthread
# Paths to workspace libraries
NOSTR_LIB = ../nostr_core_lib
C_UTILS_LIB = ../c_utils_lib
CJSON = $(NOSTR_LIB)/cjson
INCLUDES = -I$(NOSTR_LIB) -I$(C_UTILS_LIB)/src -I$(CJSON)
SRCS = src/main.c src/config.c src/context.c src/nostr_handler.c src/agent.c src/llm.c
TARGET = didactyl
all: $(TARGET)
$(TARGET): $(SRCS)
$(CC) $(CFLAGS) $(INCLUDES) -o $@ $^ \
$(NOSTR_LIB)/libnostr_core.a \
$(C_UTILS_LIB)/libc_utils.a \
$(LIBS)
clean:
rm -f $(TARGET)
Data Flow — Message Lifecycle
sequenceDiagram
participant Admin as Admin Nostr Client
participant Relay as Nostr Relays
participant OW as Didactyl Daemon
participant LLM as LLM API
Note over OW: Boot: load config.json + SYSTEM.md
OW->>Relay: Connect to relay pool
OW->>Relay: Publish kind 0 profile
OW->>Relay: Subscribe kind 4 where #p = agent_pubkey
Admin->>Relay: Send kind 4 encrypted DM to agent
Relay->>OW: Deliver kind 4 event
OW->>OW: Verify sender == admin pubkey
OW->>OW: Decrypt with NIP-04
OW->>LLM: POST /v1/chat/completions with SYSTEM.md + message
LLM->>OW: Response text
OW->>OW: Encrypt response with NIP-04
OW->>Relay: Publish kind 4 DM to admin
Relay->>Admin: Deliver encrypted response
Main Loop Pseudocode
int main(int argc, char* argv[]) {
// 1. Parse args (--config, --context, --debug)
// 2. Load config
didactyl_config_t config;
config_load("config.json", &config);
// 3. Load system context
char* system_context = context_load("SYSTEM.md");
// 4. Init nostr
nostr_init();
debug_init(debug_level);
// 5. Decode keys
nostr_decode_nsec(config.keys.nsec, config.keys.private_key);
nostr_ec_public_key_from_private_key(config.keys.private_key, config.keys.public_key);
// 6. Init components
nostr_handler_init(&config);
llm_init(&config.llm);
agent_init(&config, system_context);
// 7. Publish profile
nostr_handler_publish_profile(&config);
// 8. Subscribe to DMs
nostr_handler_subscribe_dms(&config, agent_on_message, NULL);
// 9. Daemon loop
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
while (running) {
nostr_handler_poll(100); // 100ms poll interval
}
// 10. Cleanup
agent_cleanup();
llm_cleanup();
nostr_handler_cleanup();
config_free(&config);
context_free(system_context);
nostr_cleanup();
return 0;
}
Implementation Order
- Config loader — parse
config.json, decode keys - Context loader — read
SYSTEM.mdinto string - Nostr handler — relay pool connect, kind 0 publish, kind 4 subscribe + send
- LLM client — HTTP POST to chat completions API, parse response
- Agent logic — wire DM callback to LLM and back
- Main — tie it all together with the daemon loop
- Makefile — build system
- Test — manual test with a Nostr client sending DMs to the agent
Future Enhancements (Not MVP)
- Upgrade to NIP-17 gift-wrapped DMs for better privacy
- Conversation history/memory stored as Nostr events
- Agent can store and retrieve its own notes on Nostr
- Multi-turn conversation context window
- Shell command execution
- File storage on Nostr (NIP-94/NIP-96)
- Mnemonic-based key generation for portability
- Multiple admin support with role-based access
- Agent-to-agent communication