29 KiB
Run Skill Architecture — Tradeoff Analysis
The Question
Skills today are adopted — their content is injected into the agent's context window for every matching trigger. This is "always on" and costs context tokens on every LLM call, even when the skill's capability isn't needed.
What if an agent could use a skill once and be done with it — like calling a tool?
Two approaches exist. This document analyzes both.
Approach A: Sub-Invocation (Separate LLM Call)
Concept: A skill_run tool that spins up an entirely separate LLM execution with the skill's instructions as the system prompt, runs its own tool loop to completion, and returns the result to the calling context.
sequenceDiagram
participant Admin
participant Agent as Agent - Main Context
participant SkillRun as skill_run Executor
participant LLM2 as LLM - Skill Context
Admin->>Agent: Hey, summarize this thread
Agent->>Agent: I need the summarize-thread skill
Agent->>SkillRun: skill_run d_tag=summarize-thread, args=thread_id
SkillRun->>SkillRun: Fetch skill content from Nostr
SkillRun->>SkillRun: Build isolated context window
SkillRun->>LLM2: System: skill instructions, User: args
LLM2->>SkillRun: Tool calls + reasoning
SkillRun->>SkillRun: Execute tool loop to completion
LLM2->>SkillRun: Final text response
SkillRun->>Agent: Return result as tool output
Agent->>Admin: Here is the summary...
How It Works
- The LLM in the main conversation calls
skill_runwith ad_tag(or fullkind:pubkey:d_tagaddress) andargs - The runtime fetches the skill event from Nostr (or adopted skills cache)
- A new, isolated context window is built:
- System prompt = skill content (with
{{...}}template variables resolved) - User message = the
argsparameter - Tools = the skill's
requires_tooltags (or full tool set for own skills) - LLM config = the skill's
llm,temperature,max_tokenstags (or agent defaults)
- System prompt = skill content (with
- A separate tool loop runs (same pattern as
agent_on_trigger()) - The final LLM text response is returned as the
skill_runtool result - The calling context continues with that result
Advantages
| Advantage | Detail |
|---|---|
| Zero context cost when idle | The skill's instructions never enter the main context window unless invoked |
| Isolation | The skill runs in its own context — it can't see or interfere with the main conversation |
| Per-skill model | Each skill can use a different LLM model/provider via its llm tag |
| Sandboxing | External skills can be restricted to safe tools only |
| Composability | Skills can call other skills via nested skill_run (with depth limits) |
| Try-before-adopt | Run someone else's skill once without permanently adopting it |
| Clean separation | The skill is a black box — input in, result out |
Disadvantages
| Disadvantage | Detail |
|---|---|
| Extra LLM call | Every skill_run invocation costs at least one additional LLM API call (possibly more if the skill uses tools) |
| Latency | The main conversation blocks while the sub-invocation runs its tool loop |
| No shared context | The skill doesn't know about the ongoing conversation — it only sees its args |
| Complexity | Requires managing nested execution contexts, preventing infinite recursion, handling timeouts |
| Token overhead | The skill's system prompt is paid for separately — no sharing with the main context |
Existing Design
This approach is already designed in detail in plans/tool_orchestration.md as the skill_run tool (Section 2B), including:
- Schema definition
- Execution flow with sandbox for external skills
- Tool safety classification
/runslash command for admin direct invocation
Approach B: Lazy Injection (On-Demand Context Loading)
Concept: A skill_load tool that fetches a skill's content and injects it into the current context window mid-conversation. The LLM then uses those instructions in subsequent reasoning within the same call.
sequenceDiagram
participant Admin
participant Agent as Agent - Single Context
participant LLM as LLM
Admin->>Agent: Hey, summarize this thread
Agent->>LLM: System + User message
LLM->>Agent: I need the summarize-thread skill. Call skill_load.
Agent->>Agent: Fetch skill content, append to messages
Agent->>LLM: ...previous messages + skill content as new system message
LLM->>Agent: Now executing with skill instructions in context...
Agent->>Admin: Here is the summary...
How It Works
- The LLM calls
skill_loadwith ad_tag - The runtime fetches the skill content
- The skill content is returned as the tool result (or injected as a new system message)
- The LLM continues in the same context window, now with the skill instructions available
- No separate LLM call — the skill instructions become part of the ongoing conversation
Advantages
| Advantage | Detail |
|---|---|
| No extra LLM call | The skill instructions are loaded into the current context — no sub-invocation overhead |
| Shared context | The skill has full access to the conversation history and can reason about it |
| Lower latency | No separate tool loop — the LLM just continues with more information |
| Simpler implementation | Just fetch content and return it as a tool result — no nested execution management |
| Natural flow | The LLM decides when it needs more instructions and loads them dynamically |
Disadvantages
| Disadvantage | Detail |
|---|---|
| Context window cost | Once loaded, the skill content stays in the context for the rest of the conversation |
| No isolation | The skill instructions mix with everything else — potential for instruction conflicts |
| No per-skill model | The skill runs on whatever model the current conversation is using |
| No sandboxing | The skill's instructions execute with full tool access (same as the main context) |
| Accumulation | Loading multiple skills grows the context window — could hit token limits |
| No clean boundary | The LLM might partially follow skill instructions or blend them with other context |
Implementation
This is essentially what skill_get already does — it returns skill content. The difference would be:
skill_getreturns metadata + content as a JSON blob for the LLM to readskill_loadwould inject the content as a system-level instruction that the LLM should follow
In practice, the LLM can already do this today by calling skill_get and then reasoning about the returned content. The question is whether a dedicated skill_load tool that injects content at the system level would be meaningfully different.
Approach C: Hybrid — Both, With Different Use Cases
The two approaches serve different needs and are not mutually exclusive:
graph TD
A[Agent needs a skill capability] --> B{What kind of need?}
B -->|One-shot task with clear input/output| C[skill_run - Sub-invocation]
B -->|Need skill knowledge in ongoing conversation| D[skill_load - Lazy injection]
B -->|Always need this skill| E[skill_adopt - Permanent adoption]
C --> F[Isolated execution, result returned]
D --> G[Instructions loaded into current context]
E --> H[Instructions in every matching trigger context]
style C fill:#e1f5fe
style D fill:#fff3e0
style E fill:#e8f5e9
When to Use Each
| Scenario | Best Approach | Why |
|---|---|---|
| Run a friend's skill to try it | skill_run |
Isolation + sandbox for untrusted code |
| Spell-check this message | skill_run |
Clear input/output, no conversation context needed |
| Deploy my website | skill_run |
Task-oriented, uses different tools, benefits from isolation |
| I need to know the formatting rules for Nostr posts | skill_load |
Reference material for the current conversation |
| Help me write a skill (load the skill-authoring guide) | skill_load |
The LLM needs the instructions as ongoing context |
| Always respond in a certain personality | skill_adopt |
Needed in every conversation |
The Key Distinction
skill_run= "Do this task for me and give me the result" (function call semantics)skill_load= "Teach me how to do this so I can do it myself" (knowledge injection)skill_adopt= "I always need to know this" (permanent context)
Relationship to Existing Architecture
Chain Triggers vs skill_run
Chain triggers already provide sequential skill execution — Skill A completes, then Skill B fires. But chains are pre-configured (declared in skill tags) and event-driven (fire automatically).
skill_run is dynamic — the LLM decides at runtime which skill to invoke based on the conversation. It's the difference between a cron job and a function call.
Maturity Levels
The tool_orchestration.md plan defines three maturity levels that apply to skill_run:
| Level | Execution | LLM? |
|---|---|---|
draft |
LLM interprets skill instructions | Yes |
guided |
LLM with forced tool_choice | Yes, constrained |
hardened |
Deterministic step executor | No |
A hardened skill via skill_run is essentially a tool — no LLM call, just sequential tool execution. This is the ultimate "skill as tool" pattern.
Recursive skill_run
skill_run can call skill_run (a skill invokes another skill). This needs a depth limit (like the chain depth limit of 5 in trigger_manager_fire_chains()). Each level gets its own context window and tool loop.
Implementation Considerations
For skill_run (Sub-Invocation)
The core implementation reuses the existing agent_on_trigger() pattern:
- New function:
execute_skill_run()intool_skill.c - Context building: Reuse
build_context_from_triggers()pattern but for a single skill - Tool loop: Same pattern as
agent_on_trigger()— call LLM, execute tools, repeat - LLM config: Save/restore pattern from
apply_trigger_runtime_to_llm_config() - Sandbox: Filter tools array based on skill origin (own vs external)
- Depth tracking: Static or thread-local counter to prevent infinite recursion
- Result capture: Capture the final LLM text response and return it as the tool result
For skill_load (Lazy Injection)
Minimal new code needed:
- New tool:
skill_loadintool_skill.c - Fetch: Same as
skill_get— fetch skill content from cache or Nostr - Return: Return the skill content as the tool result with a wrapper indicating these are instructions to follow
- Alternative: Inject as a system message in the messages array (requires access to the messages array from within tool execution, which the current architecture doesn't support)
The simpler version (return as tool result) works today with minimal changes. The LLM receives the skill content as a tool response and can reason about it. The more sophisticated version (system message injection) would require architectural changes to how tools interact with the message array.
Approach D: Modes — Skill Packages as Agent Personalities
The Roo Code Parallel
Roo Code has modes — Architect, Code, Ask, Debug — each with its own:
- System prompt / personality
- Set of allowed tools
- File access restrictions
- Model preferences
A mode is essentially a curated bundle of capabilities that the agent switches into. You don't load the Architect's planning instructions into the Code mode's context — you switch modes entirely.
This maps directly onto Didactyl's skill system.
Modes as Skill Packages
A mode is a named collection of skills that, when activated, replaces the agent's current context window composition. It's a different adoption list — a different set of Layer 1 skills, different tools, potentially a different LLM model.
graph TD
A[Agent receives /mode architect] --> B[Look up mode: architect]
B --> C[Mode defines skills: planning, analysis, design-patterns]
C --> D[Mode defines model: best]
C --> E[Mode defines tools: memory_*, nostr_query, skill_search]
D --> F[Reconfigure agent context for this session]
E --> F
F --> G[Agent now operates as Architect]
G --> H[All subsequent messages use architect context]
Mode Definition as a Skill
A mode is itself a skill — a meta-skill that declares which other skills compose it:
{
"kind": 31123,
"content": "You are operating in Architect mode.\n\nYour role is to plan, design, and analyze before implementation.\n- Break down complex problems into clear steps\n- Create technical specifications\n- Design system architecture\n- Do NOT write implementation code\n\n{{identity}}\n{{planning-guidelines}}",
"tags": [
["d", "mode-architect"],
["description", "Architect mode - planning and design"],
["mode", "architect"],
["llm", "best"],
["temperature", "0.7"],
["requires_skill", "identity"],
["requires_skill", "planning-guidelines"],
["requires_tool", "memory_save"],
["requires_tool", "memory_recall"],
["requires_tool", "nostr_query"],
["requires_tool", "skill_search"],
["requires_tool", "skill_list"]
]
}
{
"kind": 31123,
"content": "You are operating in Cheap mode.\n\nYou are a fast, cost-effective assistant.\n- Answer questions directly and concisely\n- Use minimal tokens\n- Do not over-explain\n\n{{identity}}",
"tags": [
["d", "mode-cheap"],
["description", "Cheap mode - fast answers with inexpensive model"],
["mode", "cheap"],
["llm", "openai/gpt-4o-mini, cheap"],
["temperature", "0.3"],
["max_tokens", "500"],
["requires_skill", "identity"],
["requires_tool", "nostr_query"],
["requires_tool", "memory_recall"]
]
}
How Modes Interact with skill_run
Your example — /skill_run cheap "how many files are in the directory" — reveals two distinct use cases:
1. One-shot mode invocation (skill_run):
Run a single task using a mode's configuration, then return to normal. The mode's model, tools, and personality apply for just that one execution.
/skill_run mode-cheap "how many files are in the directory"
This uses the skill_run sub-invocation pattern: spin up an isolated context with the mode-cheap skill's instructions, use its llm tag (cheap model), execute the tool loop, return the result. The main conversation continues unchanged.
2. Persistent mode switch (/mode):
Switch the agent's operating mode for all subsequent messages until switched again.
/mode architect
> Now operating in Architect mode. Using best model. Planning tools available.
/mode cheap
> Now operating in Cheap mode. Using gpt-4o-mini. Minimal tool set.
/mode default
> Restored default mode.
This changes which skills compose the agent's context window for DM conversations. Instead of the normal adoption-list-order composition, the mode's skill set takes over.
Mode Architecture
graph TD
subgraph Current Architecture
A[Adoption List - kind 10123] --> B[All adopted skills with dm trigger]
B --> C[Context window for every DM]
end
subgraph Mode Architecture
D[Adoption List - kind 10123] --> E{Active mode?}
E -->|No mode| F[Default: all adopted dm-triggered skills]
E -->|Mode active| G[Mode skill + its requires_skill dependencies]
F --> H[Context window]
G --> H
end
subgraph Mode Lifecycle
I[/mode architect] --> J[Set active mode = mode-architect]
J --> K[Subsequent DMs use architect context]
K --> L[/mode default]
L --> M[Clear active mode, restore normal]
end
The Three Invocation Patterns (Updated)
| Pattern | Command | Context | Persists? | Model |
|---|---|---|---|---|
| Adopted skills | (automatic) | All matching skills in adoption list | Always | Agent default |
| One-shot skill | /skill_run d_tag args |
Isolated sub-invocation | No | Skill's llm tag |
| Mode switch | /mode name |
Mode's skill set replaces DM context | Until changed | Mode's llm tag |
| One-shot mode | /skill_run mode-name args |
Isolated sub-invocation with mode config | No | Mode's llm tag |
Mode Storage
The active mode is ephemeral runtime state — not persisted to Nostr. It's a session concept:
// In agent.c or a new mode_manager
static char g_active_mode_d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0}; // empty = default mode
void agent_set_mode(const char* mode_d_tag); // NULL or empty = clear mode
const char* agent_get_mode(void);
When a mode is active, build_context_from_triggers() checks the mode skill first instead of scanning all DM-triggered skills. The mode skill's requires_skill tags define which other skills are included (Layer 2).
Mode Discovery
Modes are just skills with a ["mode", "name"] tag. They're discoverable the same way:
/skill_search mode # find all mode skills
/skill_run mode-architect "plan X" # try a mode one-shot
/mode architect # switch to it persistently
Since modes are Nostr events, they're shareable. Someone publishes a "code-reviewer" mode with specific skills, model preferences, and tool restrictions. You adopt it and /mode code-reviewer to activate it.
Relationship to Roo Code Modes
| Roo Code | Didactyl Equivalent |
|---|---|
Mode definition (.roomodes) |
Skill event with ["mode", "name"] tag |
| Mode's system prompt | Skill content (markdown instructions) |
| Mode's allowed tools | ["requires_tool", "..."] tags |
| Mode's file restrictions | ["requires_tool", "local_file_read"] presence/absence |
| Mode's model | ["llm", "..."] tag |
/mode architect |
/mode architect |
| Mode switching | /mode name slash command |
| Custom modes | Create a skill with ["mode", "name"] tag |
The key difference: Roo Code modes are local config files. Didactyl modes are Nostr events — portable, shareable, discoverable, and adoptable across agents.
Modes vs. Multiple Agents
The Tradeoff
Instead of switching one agent between Architect and Coder modes, you could run two separate agents — one Architect, one Coder. Each has its own nsec, its own skills, its own model. This is already possible today with no code changes.
| Dimension | Modes (one agent) | Multiple Agents (separate binaries) |
|---|---|---|
| Resource cost | One process, one relay connection, one LLM config | N processes, N relay connections, N LLM configs |
| Context isolation | Mode switch replaces context; previous mode's state is lost | Each agent has its own persistent context and memory |
| Shared memory | Same agent = same memory store. Architect's notes are available to Coder. | Separate agents = separate memory. Must explicitly share via Nostr events. |
| Conversation continuity | Mode switch mid-conversation is seamless — same DM thread | Different agents = different DM threads. Admin must context-switch. |
| Identity | One npub. External observers see one entity. | N npubs. Each agent is a distinct Nostr identity. |
| Specialization depth | Mode is a context overlay — personality + tools + model. Shallow specialization. | Full agent is deeply specialized — its own memories, its own learned patterns, its own social graph. |
| Operational complexity | One binary, one systemd service, one genesis config | N binaries, N services, N configs, N API keys |
| Failure blast radius | Agent crashes = all modes unavailable | One agent crashes = others still running |
| Skill sharing | All modes share the same adoption list (modes just filter it) | Each agent has its own adoption list |
| Admin UX | /mode architect — instant switch |
DM a different npub — requires client support or manual switching |
When Modes Win
- Quick context switching — "plan this, then code it" in one conversation
- Shared state — the Architect's plan is in memory when you switch to Coder mode
- Low overhead — one process, one set of relay connections
- Simple admin UX —
/mode architect,/mode coder, done - Cost control —
/mode cheapfor quick questions,/mode deepfor complex analysis
When Separate Agents Win
- Deep specialization — an agent that has spent weeks learning about your codebase vs. one that knows your financial data. These are fundamentally different knowledge bases.
- Different trust boundaries — one agent has shell access, another only has Nostr access
- Different LLM providers — one on Anthropic, one on OpenAI, one on local Ollama
- Concurrent operation — both agents working simultaneously on different tasks
- Fault isolation — one agent's crash doesn't affect the other
- Team simulation — the admin can DM the Architect to plan, then DM the Coder to implement, and each agent maintains its own persistent context about its domain
The Hybrid: Modes + Clones
The most powerful pattern combines both:
- Start with one agent with modes for quick context switching
- Clone it (via
agent_clone) when you need deep specialization - Specialize the clone — different skills, different model, different tools
- Use modes within each clone for further flexibility
Admin's Agent Fleet:
├── Agent Alpha (generalist)
│ ├── /mode architect — planning
│ ├── /mode coder — implementation
│ └── /mode cheap — quick questions
├── Agent Beta (clone, specialized: infrastructure)
│ ├── /mode deploy — deployment workflows
│ └── /mode monitor — system monitoring
└── Agent Gamma (clone, specialized: content)
├── /mode writer — long-form content
└── /mode social — Nostr social engagement
Multi-Agent Didactyl: What Would It Take?
Current Single-Agent Architecture
The codebase is deeply single-agent. Global state is everywhere:
| Global | File | Purpose |
|---|---|---|
static didactyl_config_t* g_cfg |
agent.c, nostr_handler.c |
Agent config including keys |
static tools_context_t g_tools_ctx |
agent.c |
Tool execution context |
static trigger_manager* g_trigger_manager |
agent.c |
Trigger state |
static nostr_relay_pool_t* g_pool |
nostr_handler.c |
Single relay pool |
static llm_config_t g_cfg |
llm.c |
LLM configuration |
static char* g_admin_kind0_json |
nostr_handler.c |
Admin context cache |
static char* g_agent_kind0_json |
nostr_handler.c |
Agent self-context cache |
static char* g_memory_plain |
tool_memory.c |
Agent memory |
20+ pthread_mutex_t globals |
Various | Thread safety for all the above |
Every module assumes there is exactly one agent identity, one relay pool, one LLM config, one admin, one set of keys. The keys are used directly via g_cfg->keys.private_key in ~40 places across nostr_handler.c for signing, encrypting, and decrypting.
Three Paths to Multi-Agent
Path 1: Multiple Processes (Current — No Code Changes)
Run N separate Didactyl binaries, each with its own genesis config and nsec. This is what DECENTRALIZED_DIDACTYL.md describes.
Pros: Works today. Zero code changes. Full isolation. Cons: N × resource cost. N × operational complexity. No shared state.
Server
├── /home/didactyl-architect/ (systemd: didactyl-architect.service)
│ ├── genesis.jsonc (nsec1..., model: best)
│ └── didactyl (binary)
├── /home/didactyl-coder/ (systemd: didactyl-coder.service)
│ ├── genesis.jsonc (nsec2..., model: fast)
│ └── didactyl (binary)
└── /home/didactyl-cheap/ (systemd: didactyl-cheap.service)
├── genesis.jsonc (nsec3..., model: cheap)
└── didactyl (binary)
Path 2: Multi-Agent Single Process (Major Refactor)
Refactor all global state into an agent_instance_t struct. Run multiple agent instances in one process, sharing the relay pool and HTTP server.
What changes:
typedef struct agent_instance {
didactyl_config_t config;
tools_context_t tools_ctx;
trigger_manager_t trigger_manager;
llm_config_t llm_config;
// ... all per-agent state
} agent_instance_t;
// Instead of:
static didactyl_config_t* g_cfg;
// Becomes:
agent_instance_t* agents;
int agent_count;
Scope of refactor:
- Every function that touches
g_cfg,g_tools_ctx,g_trigger_manager, or any global state needs anagent_instance_t*parameter nostr_handler.c— the largest file (~4500 lines) — usesg_cfg->keysin ~40 places. Every signing, encryption, and subscription call needs to know which agent's keys to use.llm.c— the globalg_cfgLLM config needs to become per-instanceagent.c— all context building, message handling, tool loops need per-instance state- DM routing — incoming DMs need to be dispatched to the correct agent based on the
#ptag - The HTTP API needs agent-scoped endpoints (
/api/agents/:id/prompt, etc.)
Estimated scope: Touch every .c file. ~2000-3000 lines of refactoring. High risk of regressions.
Pros: Shared relay pool (fewer connections). Shared HTTP server. Single process to manage. Cons: Massive refactor. High regression risk. Shared-process failure mode (one agent's crash kills all).
Path 3: Multi-Agent via Modes (Minimal Code — Recommended First Step)
Modes give you 80% of the multi-agent benefit with 5% of the effort:
- Different personality per mode — mode skill content
- Different model per mode — mode's
llmtag - Different tools per mode — mode's
requires_tooltags - Different temperature per mode — mode's
temperaturetag - Instant switching —
/mode architect - Shared memory — same agent, same memory store
- Shared identity — same npub, same social graph
What you lose vs. true multi-agent:
- No concurrent operation (one mode at a time)
- No independent persistent context per mode
- No fault isolation between modes
This is the Roo Code model — and it works extremely well for the single-admin use case.
Recommendation
graph TD
A[Start here] --> B[Implement modes - skill packages]
B --> C{Need concurrent agents?}
C -->|No| D[Modes are sufficient]
C -->|Yes| E{How many?}
E -->|2-3| F[Multiple processes - Path 1]
E -->|Many| G{Shared resources matter?}
G -->|No| F
G -->|Yes| H[Multi-agent refactor - Path 2]
style B fill:#e8f5e9
style D fill:#e8f5e9
style F fill:#fff3e0
style H fill:#ffebee
- Now: Implement modes (skill packages). This covers the "Architect vs Coder" use case with minimal code.
- When needed: Run multiple Didactyl processes for true concurrent agents. Already works, just operational overhead.
- If justified: Multi-agent single-process refactor. Only if you're running 5+ agents on one server and the resource overhead matters.
Implementation Layers
The features build on each other in this order:
Layer 1: skill_run (Sub-Invocation)
Already designed in tool_orchestration.md. This is the foundation — an isolated LLM execution of any skill. Enables one-shot skill usage, external skill testing, and sandboxing.
Enables: /skill_run cheap "how many files are in the directory"
Layer 2: Slash Commands
Also in tool_orchestration.md. Direct tool execution via /tool_name args. Required for /mode and /skill_run as admin commands.
Enables: /skill_run, /mode, /help
Layer 3: Modes
Mode skills with ["mode", "name"] tags. /mode slash command to switch. Runtime tracks active mode and adjusts context composition.
Enables: /mode architect, /mode cheap, persistent mode switching
Layer 4: Hardened Skills
Deterministic step executor for skills that don't need LLM reasoning. A mode could include hardened skills as tools.
Enables: Skills that execute as pure tool sequences — zero LLM cost
Recommendation
Implement in this order:
-
skill_run(sub-invocation) — the foundation. Already designed. Enables one-shot skill execution with isolated context, per-skill model, and sandboxing. -
Slash commands (
/skill_run,/help) — admin direct invocation. Required for the mode UX. -
Modes (
/mode name) — skill packages that reconfigure the agent's context. Builds onskill_runfor one-shot mode usage and slash commands for persistent switching. -
Hardened skill execution — deterministic tool sequences. The ultimate "skill as tool" pattern with zero LLM cost.