13 KiB
Markdown Context Window — Implementation Plan
The context window that the agent sees should be a nicely formatted markdown document. Skills are the building blocks, and the runtime assembles them into a coherent document with proper heading hierarchy, role markers for the LLM API, and clean formatting throughout.
See also: example_context_v2.md for visual examples.
Design Decisions
1. One generic JSON-to-markdown converter
A single function converts any JSON value into readable markdown. No per-tool formatters. Tools continue to return JSON; the template engine calls the converter when injecting tool output into the markdown document.
char* json_to_markdown(const char* json_string);
Conversion rules:
- String: output as-is (inline)
- Number: output as-is (inline)
- Boolean:
true/false - Null: (empty)
- Object: bullet list with bold keys
- **key**: value- Nested objects indent one level
- Array of primitives: comma-separated inline or bullet list
- Array of objects: bullet list, each item shows its fields
Examples
Simple object:
{"name":"Simon","npub":"npub1kfc89...","nip05":"simon@nostr"}
- **name**: Simon
- **npub**: npub1kfc89...
- **nip05**: simon@nostr
Nested object:
{"agent":{"name":"Simon","npub":"npub1kfc89..."},"version":"v0.2.12"}
- **agent**:
- **name**: Simon
- **npub**: npub1kfc89...
- **version**: v0.2.12
Array of objects:
[{"content":"Deployed v0.2.12","created_at":1774263529},{"content":"Working on skills","created_at":1774263600}]
- **content**: Deployed v0.2.12
- **created_at**: 1774263529
- **content**: Working on skills
- **created_at**: 1774263600
Plain string (already markdown):
"You are Simon, a sovereign AI agent."
You are Simon, a sovereign AI agent.
Edge case: tool content that is already markdown
Some tools may return markdown text in their content field (e.g., a skill
that already formats its own output). The converter detects this: if the
input is a plain JSON string (not an object/array), it passes through
unchanged. This means skill authors can return pre-formatted markdown from
tools if they want precise control.
Edge case: very large JSON
For large arrays (e.g., 50 Nostr events from a query), the converter should
truncate with a note: *(... and 42 more items)*. A reasonable default
limit is 8 items for arrays.
2. Runtime owns h1, heading bump for skills
- The runtime emits
# Agent Nameas the document title - All headings in skill content get bumped one level:
#→##,##→### - Template variables are resolved BEFORE the heading bump
- Layer 2 skills (embedded via
{{skill_d_tag}}) get the same bump as their containing layer 1 skill (because they are resolved inline first)
3. Role markers parsed
Skill content can contain system: and user: markers at the start of a
line. The runtime splits on these markers to produce the API messages array.
system:— content goes into the system messageuser:— content goes into the user messageassistant:— content goes into an assistant message (rare, for few-shot)- No marker — defaults to
system: - Multiple skills can contribute
system:sections (concatenated in order) - Multiple
user:sections get concatenated
4. Skills separated by horizontal rules
Layer 1 skills are separated by --- in the assembled document. This gives
visual structure and helps the LLM distinguish between different instruction
blocks.
5. Context log shows the markdown document
The append_context_log() function logs the assembled markdown document
so you can inspect exactly what the agent sees, formatted as readable
markdown rather than raw JSON message arrays.
Implementation Steps
Step 1: Create json_to_markdown converter
File: src/json_to_markdown.c / src/json_to_markdown.h
New module with a single public function:
// Convert a JSON string to markdown text.
// Returns a malloc'd string. Caller frees.
// If input is a plain string (not object/array), passes through unchanged.
// Objects become bullet lists with bold keys.
// Arrays become bullet lists of items.
// Nested structures indent appropriately.
// Large arrays truncate at max_items with a note.
char* json_to_markdown(const char* json_string, int max_array_items);
Implementation:
- Parse JSON with cJSON
- Recursive walk of the JSON tree
- Build output string with the append_text pattern used elsewhere
- Handle: string, number, bool, null, object, array
- Indent nested structures with 2-space indentation
- Truncate arrays beyond max_array_items
Step 2: Create context_roles splitter
File: src/context_roles.c / src/context_roles.h
New module that splits a markdown document on role markers:
typedef struct {
char* system_content; // Everything under system: markers
char* user_content; // Everything under user: markers
char* assistant_content; // Everything under assistant: markers (rare)
} context_roles_t;
// Split markdown text on role markers (system:, user:, assistant:)
// at the start of a line. Unmarked content defaults to system.
// Returns 0 on success, -1 on error.
int context_roles_split(const char* markdown, context_roles_t* out);
void context_roles_free(context_roles_t* roles);
Step 3: Create heading_bump utility
File: src/context_format.c / src/context_format.h
Utility functions for markdown formatting in context assembly:
// Bump all markdown headings in text by one level (# -> ##, ## -> ###, etc.)
// Returns a malloc'd string. Caller frees.
char* context_bump_headings(const char* text);
// Build the runtime document title from agent identity.
// Returns "# Agent Name\n\n" as a malloc'd string.
char* context_build_title(tools_context_t* ctx);
Step 4: Modify prompt_template_resolve_inline_variables
File: src/prompt_template.c
After extracting the content field from a tool result (line ~179), pass it
through json_to_markdown() before inserting into the template:
Current code (simplified):
cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
if (content && cJSON_IsString(content)) {
replacement_owned = strdup(content->valuestring);
}
New code:
cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
if (content && cJSON_IsString(content)) {
// Try to convert JSON content to markdown
char* md = json_to_markdown(content->valuestring, 8);
replacement_owned = md ? md : strdup(content->valuestring);
}
If the content is already a plain string (not JSON), json_to_markdown
passes it through unchanged. If it is a JSON object/array, it gets
converted to a markdown bullet list.
Step 5: Modify build_context_from_triggers
File: src/agent.c
Update the context assembly function to:
- Resolve template variables (already done)
- Bump headings in each skill's content
- Build the document title
- Concatenate with
---separators - Return the complete markdown document (not yet split by roles)
Current flow:
for each matching skill:
expand = resolve_skill_references(skill.content)
append expand to output with --- separator
New flow:
title = context_build_title(tools_ctx)
output = title
for each matching skill:
expanded = resolve_skill_references(skill.content)
resolved = prompt_template_resolve_inline_variables(expanded)
bumped = context_bump_headings(resolved)
append bumped to output with --- separator
Step 6: Modify agent_on_message and agent_on_trigger
File: src/agent.c
Update the callers of build_context_from_triggers to use role splitting:
Current flow:
dm_context = build_context_from_triggers(DM, ...)
// dm_context goes entirely into system message
// user message is the raw DM text
llm_chat(dm_context, message)
New flow:
markdown_doc = build_context_from_triggers(DM, ...)
context_roles_split(markdown_doc, &roles)
// roles.system_content -> system message
// roles.user_content -> user message (or fall back to raw DM text)
llm_chat(roles.system_content, roles.user_content ?: message)
Step 7: Update append_context_log
File: src/agent.c
Log the assembled markdown document instead of (or in addition to) the raw
JSON messages array. The log entry should show the readable markdown so you
can open context.log.md and see exactly what the agent sees.
Current format:
[{"role":"system","content":"...escaped..."},{"role":"user","content":"..."}]
New format:
system:
# Simon — Didactyl Agent
You are Simon...
## Rules
- ...
---
## Chat
Respond helpfully...
user:
Hey Simon, who mentioned me today?
Step 8: Update DM history formatting
File: src/nostr_handler.c (or wherever DM history is built)
The conversation history that gets injected into the context should be formatted as a markdown list instead of inline JSON:
Current:
[{"role":"assistant","content":"Started up..."},{"role":"user","content":"Hello"}]
New:
## Conversation History
- **You**: Started up and is online at 2026-03-23 09:22:48.
- **Admin**: Hello
- **You**: Hello! How can I help?
This could be handled by the generic json_to_markdown converter if the
history is passed as JSON, or by a dedicated history formatter if we want
the cleaner **You** / **Admin** labels instead of **role**.
Note: This is the one place where a small specialized formatter adds real
value — the generic converter would produce **role**: assistant /
**content**: Started up... which is less readable than **You**: Started up....
A simple function that maps role names to display labels handles this.
Step 9: Update documentation
Files: docs/SKILLS.md, docs/CONTEXT.md
Update the documentation to reflect:
- Skill content is markdown with role markers
- The runtime assembles a coherent markdown document
- Heading bump behavior
- The generic JSON-to-markdown conversion for template variables
- Updated examples showing the new format
Files Changed
| File | Change |
|---|---|
src/json_to_markdown.c |
NEW — Generic JSON to markdown converter |
src/json_to_markdown.h |
NEW — Header for converter |
src/context_roles.c |
NEW — Role marker splitter |
src/context_roles.h |
NEW — Header for role splitter |
src/context_format.c |
NEW — Heading bump and title builder |
src/context_format.h |
NEW — Header for context formatting |
src/prompt_template.c |
MODIFY — Use json_to_markdown for template variable output |
src/agent.c |
MODIFY — Use heading bump, role splitting, updated context log |
src/nostr_handler.c |
MODIFY — Format DM history as markdown list |
Makefile |
MODIFY — Add new source files to build |
docs/SKILLS.md |
MODIFY — Update skill content format documentation |
docs/CONTEXT.md |
MODIFY — Update context assembly documentation |
What Does NOT Change
- Tool implementations — All tools continue to return JSON. No changes to any tool_*.c files.
- LLM API interface — Still sends OpenAI-compatible messages array. The markdown is the content within the messages, not the transport format.
- Runtime tool results — Tool call/result messages during multi-turn loops stay as JSON. Only template variable injection gets markdown conversion.
- Skill event format on Nostr — Skills are still stored as kind 31123/31124 events. The content field is still markdown with role markers. No protocol change.
- Adoption list — No changes to kind 10123 or skill ordering.
Risk Assessment
Low risk
json_to_markdownis a pure function with no side effectscontext_roles_splitis a simple string splittercontext_bump_headingsis a line-by-line string operation- All new code is additive — existing behavior preserved for skills without role markers (defaults to system)
Medium risk
- Changing
prompt_template_resolve_inline_variablesto use json_to_markdown could change the content of existing template variables. Need to verify that existing skills still work correctly with markdown output instead of raw JSON. Mitigation: json_to_markdown passes through plain strings unchanged, so skills that already return non-JSON content are unaffected.
Testing approach
- Unit test json_to_markdown with various JSON inputs
- Unit test context_roles_split with various role marker patterns
- Unit test context_bump_headings with various heading levels
- Integration test: run existing skills and compare context.log.md output before and after to verify no regressions