Files
didactyl/plans/didactyl_agentic.md

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

  1. Tool registry frameworktools.h, tools.c with registration and dispatch
  2. LLM tool-calling protocol — extend llm.c with llm_chat_with_tools()
  3. Agent loop — rewrite agent.c with the tool-call loop
  4. nostr_post tool — publish any kind event
  5. nostr_query tool — query relays with filters
  6. local_shell_exec tool — sandboxed shell command execution
  7. nostr_dm tool — NIP-17 private DMs
  8. Config extension — parse tools config section
  9. Security hardening — timeouts, output limits, allowlists
  10. 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