11 KiB
Didactyl — Agentic Tool-Use Architecture Plan
Vision
Didactyl is a Nostr-first sovereign AI agent. Where traditional agents ride on top of Linux — reading files, writing to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The Linux host is just the runtime substrate.
The agent receives commands via encrypted Nostr DMs, uses an LLM to reason about them, and can take actions in its environment by calling tools — primarily Nostr-native tools, with shell access as an escape hatch.
Current State (MVP)
The MVP is a linear chat pipeline:
DM in → LLM chat → DM out
The agent can only talk. It cannot act.
Target State (Agentic)
The agent has an agent loop with tool-calling:
DM in → [LLM decides → execute tool → observe result → repeat] → DM out
The LLM can request actions, observe results, and iterate until it has a final answer.
Architecture
flowchart TD
subgraph NOSTR[Nostr Layer]
EVENTS[Events = Data/Files]
RELAYS[Relays = Network]
DMS[DMs = Command Channel]
BLOSSOM[Blossom = Blob Storage]
end
subgraph LINUX[Linux Layer]
SHELL[Shell = Escape Hatch]
end
subgraph AGENT[Didactyl Agent Loop]
LLM[LLM Brain]
TOOLS[Tool Registry]
LOOP{Agent Loop}
end
DMS -->|incoming command| LOOP
LOOP --> LLM
LLM -->|tool_call| TOOLS
TOOLS -->|nostr tools| NOSTR
TOOLS -->|local_shell_exec| SHELL
TOOLS -->|result| LOOP
LLM -->|final answer| DMS
How Tool Calling Works
The Protocol
All communication with the LLM is JSON via the OpenAI-compatible chat completions API. Tool definitions are sent as a structured tools parameter in every request — not in SYSTEM.md.
Request with tools
{
"model": "gpt-4o-mini",
"messages": [
{"role": "system", "content": "You are Didactyl..."},
{"role": "user", "content": "Post a joke"}
],
"tools": [
{
"type": "function",
"function": {
"name": "nostr_post",
"description": "Publish a Nostr event to connected relays",
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "integer", "description": "Event kind number"},
"content": {"type": "string", "description": "Event content"}
},
"required": ["kind", "content"]
}
}
}
]
}
LLM responds with tool_call
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "nostr_post",
"arguments": "{\"kind\": 1, \"content\": \"Why do programmers prefer dark mode? Because light attracts bugs!\"}"
}
}]
}
}]
}
When content is null and tool_calls is present: execute the tool, don't reply yet.
Didactyl sends tool result back
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"success\": true, \"event_id\": \"abc...\", \"relays_published\": 3}"
}
LLM gives final answer
{
"choices": [{
"message": {
"role": "assistant",
"content": "Done! I posted a joke to 3 relays."
}
}]
}
When content has text and no tool_calls: this is the final answer, DM it back.
Message Lifecycle
sequenceDiagram
participant Admin as Admin via Nostr DM
participant Agent as Didactyl Agent Loop
participant LLM as LLM API
participant Tools as Tool Registry
participant Nostr as Nostr Relays
participant Shell as Local Shell
Admin->>Agent: Encrypted DM: Post a joke
Agent->>LLM: messages + tool definitions
loop Until final answer
LLM->>Agent: tool_call: nostr_post kind=1 content=joke
Agent->>Tools: dispatch nostr_post
Tools->>Nostr: Publish kind 1 event
Nostr->>Tools: OK
Tools->>Agent: result JSON
Agent->>LLM: tool result + continue
end
LLM->>Agent: final text: Done! Posted to 3 relays
Agent->>Admin: Encrypted DM reply
Tool Inventory
Tier 1: Core Nostr Tools
| Tool | Description | Underlying API |
|---|---|---|
nostr_post |
Publish any kind event to relays | nostr_create_and_sign_event() + nostr_relay_pool_publish_async() |
nostr_query |
Query relays with filters, return matching events | nostr_relay_pool_query_sync() |
nostr_dm |
Send a private DM via NIP-17 gift wrap | nostr_nip17_send_dm() |
nostr_profile |
Update the agent's kind 0 metadata | nostr_create_and_sign_event(0, ...) |
Tier 2: Extended Nostr Tools
| Tool | Description | Notes |
|---|---|---|
nostr_follow |
Publish/update kind 3 contact list | Build tags array of p-tags |
nostr_encrypt |
Encrypt content for a pubkey | NIP-44 |
nostr_decrypt |
Decrypt content from a pubkey | NIP-44 |
nostr_relay_info |
Get relay information document | NIP-11 |
nostr_verify_nip05 |
Verify a NIP-05 identifier | NIP-05 |
Tier 3: Storage Tools
| Tool | Description | Notes |
|---|---|---|
nostr_note_save |
Store agent data as kind 30078 event | Agent's filesystem on Nostr |
nostr_note_load |
Retrieve stored data events | Query own events |
blossom_upload |
Upload blob to Blossom server | Future |
blossom_download |
Download blob from Blossom | Future |
Tier 4: Linux Escape Hatch
| Tool | Description | Notes |
|---|---|---|
local_shell_exec |
Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
File Structure
src/
├── main.c # Entry point, daemon loop (existing)
├── config.c / .h # Configuration parsing (existing, extended)
├── context.c / .h # SYSTEM.md loader (existing)
├── nostr_handler.c / .h # Relay pool, subscriptions (existing)
├── llm.c / .h # LLM client (existing, extended for tool calling)
├── agent.c / .h # Agent logic (existing, rewritten for agent loop)
├── tools.h # Tool definition struct, registry API (NEW)
├── tools.c # Tool registry, dispatch, JSON helpers (NEW)
├── tool_nostr.c # Nostr-native tools implementation (NEW)
├── tool_shell.c # Shell execution with sandboxing (NEW)
└── secp_compat.c # secp256k1 compatibility (existing)
Key Data Structures
Tool definition
typedef struct {
const char* name;
const char* description;
const char* parameters_json; // JSON Schema for parameters
char* (*execute)(const char* args_json, void* ctx);
} tool_t;
Tool registry
typedef struct {
tool_t* tools;
int count;
int capacity;
didactyl_config_t* cfg;
nostr_relay_pool_t* pool;
} tool_registry_t;
LLM tool call (parsed from response)
typedef struct {
char id[64];
char name[64];
char* arguments_json;
} llm_tool_call_t;
LLM response (extended)
typedef struct {
char* content; // final text, or NULL if tool calls
llm_tool_call_t* tool_calls;
int tool_call_count;
} llm_response_t;
Conversation message (for multi-turn)
typedef enum {
MSG_ROLE_SYSTEM,
MSG_ROLE_USER,
MSG_ROLE_ASSISTANT,
MSG_ROLE_TOOL
} msg_role_t;
typedef struct {
msg_role_t role;
char* content;
llm_tool_call_t* tool_calls; // for assistant messages
int tool_call_count;
char tool_call_id[64]; // for tool result messages
} conversation_msg_t;
Agent Loop Pseudocode
void agent_on_message(const char* sender, const char* message) {
conversation_msg_t messages[MAX_TURNS * 2 + 2];
int msg_count = 0;
// Build initial messages
messages[msg_count++] = {SYSTEM, system_context};
messages[msg_count++] = {USER, message};
for (int turn = 0; turn < MAX_TOOL_TURNS; turn++) {
llm_response_t resp = llm_chat_with_tools(messages, msg_count, tool_registry);
if (resp.content != NULL) {
// Final answer — send DM and done
nostr_handler_send_dm(sender, resp.content);
return;
}
// Tool calls — execute each one
messages[msg_count++] = {ASSISTANT, NULL, resp.tool_calls, resp.tool_call_count};
for (int i = 0; i < resp.tool_call_count; i++) {
char* result = tools_execute(registry, resp.tool_calls[i].name, resp.tool_calls[i].arguments_json);
messages[msg_count++] = {TOOL, result, .tool_call_id = resp.tool_calls[i].id};
}
}
// Max turns exceeded
nostr_handler_send_dm(sender, "I hit my tool-use limit for this request.");
}
Security Model
| Control | Description | Config key |
|---|---|---|
| Admin-only access | Only admin pubkey can send commands | admin.pubkey (existing) |
| Max tool turns | Limit iterations per request | tools.max_turns |
| Shell timeout | Kill shell commands after N seconds | tools.shell_timeout_seconds |
| Shell allowlist | Optional list of allowed commands | tools.shell_allowlist |
| Working directory | Confine file operations | tools.working_directory |
| Output size limit | Cap tool output to prevent memory issues | tools.max_output_bytes |
Config Extension
{
"tools": {
"enabled": true,
"max_turns": 10,
"shell": {
"enabled": true,
"timeout_seconds": 30,
"max_output_bytes": 65536,
"working_directory": "/home/didactyl",
"allowlist": []
}
}
}
Implementation Order
- Tool registry framework —
tools.h,tools.cwith registration and dispatch - LLM tool-calling protocol — extend
llm.cwithllm_chat_with_tools() - Agent loop — rewrite
agent.cwith the tool-call loop nostr_posttool — publish any kind eventnostr_querytool — query relays with filterslocal_shell_exectool — sandboxed shell command executionnostr_dmtool — NIP-17 private DMs- Config extension — parse tools config section
- Security hardening — timeouts, output limits, allowlists
nostr_note_save/nostr_note_load— agent data storage on Nostr
Future: Nostr as the OS
Once the tool system is in place, the path to full Nostr-native operation:
- Config on Nostr — store config.json as a kind 30078 event, load on boot
- SYSTEM.md on Nostr — store system context as a Nostr event
- Conversation memory — store conversation history as Nostr events
- Agent-to-agent — agents can DM each other and use tools cooperatively
- Blossom integration — binary data storage via Blossom servers
- Self-update — agent can modify its own SYSTEM.md on Nostr