nekoni
AGENTS.md16 starsChanged 8 months agoMITView on GitHub
This document covers the internals of the nekoni agent: how it processes messages, how to extend it with tools and skills, and how the security layer works end-to-end. A message travels through these layers before the user gets a response: File: apps/agent/src/nekoni_agent/agent/loop.py The loop implements a simplified ReAct (Reason + Act) pattern: Tool call format the LLM must output: The parser first tries json.loads(response), then falls back to a regex search for embedded JSON — accommodating models that add prose…
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
The file
We split this agents.md into the rules it gives an agent
8 rules. About 0 are common, 4 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 254
Document ID is sha256(text)[:16] — re-ingesting the same content is idempotent.
- Distinctive: line 350
All outgoing signaling messages are signed:
- Distinctive: line 357
After the DTLS-encrypted DataChannel opens, a 4-step application-layer mutual auth runs (same for mobile and web app):
- Distinctive: line 375
All signatures (signaling + DataChannel):
- Distinctive: line 392
All messages are JSON. After auth, the channel handles:
- Very specific: line 421
Every step in the agent loop emits a TraceEvent broadcast to all connected dashboard WebSocket clients (/ws/traces).
- Very specific: line 492
Keep execute() async — use asyncio.get_event_loop().run_in_executor() for blocking work
- Very specific: line 494
Keep description concise — it goes into the LLM's context window
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Agent Internals
This document covers the internals of the nekoni agent: how it processes messages, how to extend it with tools and skills, and how the security layer works end-to-end.
Table of Contents
- Request Lifecycle
- ReAct Loop
- Tool System
- Skill System
- RAG Pipeline
- Memory
- WebRTC + DataChannel Auth
- DataChannel Message Protocol
- Trace Events
- Adding a Tool
- Code Style
Request Lifecycle
A message travels through these layers before the user gets a response:
Mobile or Web App (DataChannel)
│
▼
DataChannelHandler._handle_message() # webrtc/channel.py
│ auth check (_auth_complete == True)
│ route by msg_type prefix
├─► rag_* → _handle_rag()
├─► skill_* / cron_* → _handle_skill()
└─► plain message → on_message callback
▼
main._on_datachannel_message() # main.py
│ keyed by clientId — supports multiple concurrent peers (mobile + web app simultaneously)
│ look up or create SessionContext
▼
AgentLoop.run() # agent/loop.py
│ 1. RAG retrieval (top-k chunks, filtered by min score)
│ 2. Build system prompt (tools JSON + RAG context)
│ 3. LLM call (Ollama), streaming chunks back to mobile
│ 4. Parse response — tool call? → execute → loop
│ text response? → done
▼
trace_manager.emit() # api/ws.py
│ broadcast each step to dashboard WebSocket
▼
DataChannel.send(response) # back to mobileReAct Loop
File: apps/agent/src/nekoni_agent/agent/loop.py
The loop implements a simplified ReAct (Reason + Act) pattern:
user message
│
├─► RAG query (ChromaDB, top_k=5, min_score=0.3)
│ → inject matching chunks into system prompt
│
└─► for iteration in range(max_react_iterations=8):
│
├─► Ollama chat(messages) ← streaming, chunks sent to mobile live
│
├─► parse response for tool_call JSON
│ {"tool_call": {"name": "...", "arguments": {...}}}
│
├─► if tool call found:
│ execute tool
│ append (assistant: tool_call_json) to context
│ append (tool: result) to context
│ continue loop
│
└─► if plain text: return as final responseTool call format the LLM must output:
{"tool_call": {"name": "get_time", "arguments": {"timezone": "UTC"}}}The parser first tries json.loads(response), then falls back to a regex search for embedded JSON — accommodating models that add prose around the call.
Session context (agent/context.py) keeps a rolling window of the last 20 messages. System messages are pinned; oldest non-system messages are dropped when the limit is exceeded.
Tool System
Files: tools/base.py, tools/registry.py, tools/builtin/
Architecture
Tool (ABC)
├── name: str
├── description: str
├── parameters_schema: dict ← JSON Schema, sent to LLM
└── execute(**kwargs) → Any ← called by AgentLoop
ToolRegistry
├── register(tool)
├── execute(name, **kwargs)
└── to_json_schema() → list[dict] ← injected into system promptBuilt-in Tools
| Tool | File | Description |
get_time | builtin/get_time.py | Current date/time, optional timezone (zoneinfo) |
web_search | builtin/web_search.py | DuckDuckGo instant-answer API, no key required |
rag_query | builtin/rag_query.py | Query ChromaDB knowledge base, returns top-k chunks |
How Tools Are Exposed to the LLM
At startup, ToolRegistry.to_json_schema() serializes all tools:
[
{
"name": "get_time",
"description": "Get the current date and time.",
"parameters": {
"type": "object",
"properties": {
"timezone": {"type": "string", "description": "..."}
},
"required": []
}
}
]This JSON is embedded directly into the system prompt so the LLM knows what it can call and how to format calls.
Skill System
Files: skills/models.py, skills/runner.py, skills/scheduler.py
Skills are named prompt templates stored in SQLite. They can be run on demand or scheduled via cron expressions. Manageable from both the web dashboard and the mobile app over WebRTC.
Architecture
Skill (SQLite row)
├── id: UUID
├── name: str
├── description: str
├── prompt: str ← markdown prompt sent to the agent loop
└── createdAt: int
CronJob (SQLite row)
├── id: UUID
├── skillId: UUID ← foreign key to skill
├── cronExpression: str ← standard 5-field cron (APScheduler)
├── enabled: bool
├── lastRun: int | None
└── createdAt: intRunning a Skill
run_skill(skill_id, prompt, agent_loop, sessions, trace_cb)
│
├─► load or create SessionContext (session_id = "skill:{skill_id}")
├─► emit skill_event trace (event="skill_run_start")
├─► AgentLoop.run(prompt) ← same path as user messages
├─► save messages to episodic memory
└─► emit skill_event trace (event="skill_run_complete")Scheduler
SkillScheduler wraps APScheduler's AsyncIOScheduler. On startup it loads all enabled cron jobs from SQLite and schedules them via CronTrigger.from_crontab(). When a cron job fires, it calls run_skill() and then updates lastRun.
skill_scheduler.configure(agent_loop, sessions, trace_cb)
await skill_scheduler.start() # loads DB, starts APScheduler
skill_scheduler.schedule_job(job) # add/update a job
skill_scheduler.unschedule_job(id) # remove a jobManagement via REST
| Method | Path | Description |
GET | /api/skills | List all skills |
POST | /api/skills | Create skill {name, prompt, description} |
PUT | /api/skills/{id} | Update skill fields |
DELETE | /api/skills/{id} | Delete skill + its cron jobs |
POST | /api/skills/{id}/run | Run skill immediately |
GET | /api/cron | List all cron jobs |
POST | /api/cron | Create cron job {skillId, cronExpression, enabled} |
PUT | /api/cron/{id} | Update expression or enabled state |
DELETE | /api/cron/{id} | Delete cron job |
POST | /api/cron/{id}/run | Trigger cron job immediately |
Management via WebRTC DataChannel
The mobile app manages skills over the authenticated DataChannel using these message types:
| type (mobile → agent) | response type | Description |
skill_list | skill_list_response | Fetch all skills |
skill_create | skill_response | Create skill |
skill_update | skill_response | Update skill |
skill_delete | skill_delete_response | Delete skill |
skill_run | skill_run_response | Run skill now |
cron_list | cron_list_response | Fetch all cron jobs |
cron_create | cron_response | Create cron job |
cron_update | cron_response | Update cron job |
cron_delete | cron_delete_response | Delete cron job |
RAG Pipeline
Files: rag/pipeline.py, rag/embedder.py, rag/store.py
Ingest
document (file or text)
│
├─► extractor: plain text, PDF via pypdf (required for PDFs)
│
├─► chunker: split into ~512-token windows, 50-token overlap
│ (approximated as chars/4)
│
├─► embedder: sentence-transformers all-MiniLM-L6-v2
│ runs in thread pool executor (non-blocking)
│
└─► ChromaDB upsert
collection: "nekoni_docs"
similarity: cosine
persistence: data/chroma/
metadata: {doc_id, source, chunk_index}Document ID is sha256(text)[:16] — re-ingesting the same content is idempotent.
Supported file types: plain text, PDF (pypdf must be installed), any text-decodable format.
Query
user query string
│
├─► embed with all-MiniLM-L6-v2
│
└─► ChromaDB query (cosine similarity, top_k=5)
filter: score >= rag_min_score (default 0.3)
returns: [{content (truncated to 600 chars), source, score}, ...]The top chunks are injected into the system prompt before the LLM call:
Relevant context from your knowledge base:
[document.pdf]: chunk text here
---
[notes.txt]: another chunkDocument Management
| Method | Path | Description |
GET | /api/rag/documents | List all documents [{doc_id, source, chunks}] |
DELETE | /api/rag/documents/{doc_id} | Delete document and all its chunks |
POST | /api/ingest | Ingest a file (multipart/form-data) |
Client Upload Protocol (WebRTC)
Used by both the mobile app and the web app. Large files are chunked into 15 KB base64 segments sent as sequential DataChannel messages:
mobile → {type: "rag_upload_start", uploadId, filename, totalChunks}
mobile → {type: "rag_upload_chunk", uploadId, index, content} × N
agent → reassemble → ingest_file() → {type: "rag_upload_response", doc_id, chunks}
mobile → {type: "rag_list"} → agent → {type: "rag_list_response", documents: [...]}
mobile → {type: "rag_delete", doc_id} → agent → {type: "rag_delete_response"}Memory
Short-term: Working Memory (memory/short.py)
An in-process bounded deque (maxlen=50). Stores typed entries for the current session.
mem = WorkingMemory(maxlen=50)
mem.add("observation", "User mentioned they like jazz")
mem.get_recent(n=10) # last 10 entriesLong-term: Episodic Memory (memory/long.py)
SQLite-backed via aiosqlite. Stores the full message history across sessions.
CREATE TABLE episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- user / assistant / tool
content TEXT NOT NULL,
timestamp REAL NOT NULL
);WebRTC + DataChannel Auth
Multi-peer Support
The agent maintains a separate RTCPeerConnection per connected client, keyed by clientId. Multiple devices (e.g. mobile app and web app simultaneously) can be connected at the same time. Each peer goes through its own auth handshake and maintains its own session context.
WebRTC Peer (webrtc/peer.py)
On startup the agent:
- Loads or generates its Ed25519 identity key
- Opens a WebSocket to the signal server and joins its fixed room
- Waits for
peer_joinedevents (one per connecting client) - On receiving an
offer, creates a newRTCPeerConnection(aiortc) for that client, generates an answer, sends it back - On
icecandidate, forwards to the correct peer via signaling - Once DataChannel is established, hands off to
DataChannelHandler(one instance per peer)
HTTPS / TLS
The web app connects to the agent over HTTPS (:8443) to satisfy browser security requirements. A scripts/gen_cert.py generates a self-signed certificate on first run; scripts/run_agent.py starts an asyncio SSL termination proxy that accepts TLS on :8443 and pipes raw bytes to the plain HTTP uvicorn server on :8000. A single uvicorn/FastAPI instance handles all traffic — there is no second app initialisation.
All outgoing signaling messages are signed:
sig = sign_payload({k: v for k, v in msg.items()}, self._private_key)DataChannel Handshake (webrtc/channel.py)
After the DTLS-encrypted DataChannel opens, a 4-step application-layer mutual auth runs (same for mobile and web app):
Step 1 Client → Agent {type:"hello", pubKey, nonce_m}
Step 2 Agent → Client {type:"challenge", nonce_a, sig=sign(nonce_m)}
Step 3 Client → Agent {type:"response", sig=sign(nonce_a)}
Step 4 Agent → Client {type:"ready"}- Step 1: Agent checks
pubKeyis inapproved_devices.json— if not, closes immediately - Step 2: Agent signs client's nonce with its identity key so the client can verify it's talking to the real agent (matches QR-scanned pubKey)
- Step 3: Client signs agent's nonce — proves possession of the registered private key
- Step 4: Both sides are authenticated; normal message exchange begins
Any failure at any step sends {type:"auth_failed", reason} and closes the channel.
Signature Format
All signatures (signaling + DataChannel):
sig = base64url(Ed25519Sign(UTF8(JSON.stringify(payload_without_sig, sorted_keys)), senderPrivKey))- Payload is JSON-serialized with sorted top-level keys only, no extra whitespace
- Signature is Ed25519 over the raw UTF-8 encoded JSON bytes (no pre-hashing)
- Encoded as base64url without padding
- Python:
cryptographylibrary,Ed25519PrivateKey.sign(payload_bytes) - TypeScript:
@noble/ed25519v3,ed.sign(payloadBytes, privKeyBytes) - React Native:
tweetnacl,nacl.sign.detached(payloadBytes, secretKeyBytes)
DataChannel Message Protocol
All messages are JSON. After auth, the channel handles:
Chat messages
{"type": "message", "content": "...", "sessionId": "..."}Response: streaming {"type": "chunk", "content": "..."} tokens, then {"type": "message_end"} (or {"type": "message", "content": "..."} if no streaming occurred).
Ping/pong
{"type": "ping"} → {"type": "pong"}Session sync
{"type": "sync", "sessionId": "...", "afterTimestamp": 0}
→ {"type": "sync_response", "messages": [...]}RAG operations
See Mobile Upload Protocol above.
Skill/cron operations
See Management via WebRTC DataChannel above.
Trace Events
Every step in the agent loop emits a TraceEvent broadcast to all connected dashboard WebSocket clients (/ws/traces).
interface TraceEvent {
id: string
sessionId: string
timestamp: number // Unix ms
type: "llm_call" | "tool_call" | "rag_query" | "skill_event" | "error" | "message"
data: Record<string, unknown>
parentId?: string
}Event types emitted by AgentLoop.run():
| type | when | data fields |
rag_query | before every LLM call | {query} |
llm_call | each LLM call | {iteration, messages_count} |
tool_call | tool invocation | {tool, arguments, iteration} |
tool_call | tool completion | {tool, result, duration_ms, status} |
error | LLM or tool error | {error} |
message | final response | {role: "agent", content} |
skill_event | skill lifecycle / system events | {event, ...} |
Trace sessions: The /api/traces/sessions endpoint returns distinct sessionId values with event counts and last timestamps. The dashboard Traces page can filter by session.
Adding a Tool
- Create
apps/agent/src/nekoni_agent/tools/builtin/my_tool.py:
from typing import Any
from ..base import Tool
class WeatherTool(Tool):
@property
def name(self) -> str:
return "get_weather"
@property
def description(self) -> str:
return "Get current weather for a city."
@property
def parameters_schema(self) -> dict:
return {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'London'",
},
},
"required": ["city"],
}
async def execute(self, city: str) -> dict:
return {"city": city, "temp": "22°C", "condition": "sunny"}- Register in
apps/agent/src/nekoni_agent/main.py:
from .tools.builtin.my_tool import WeatherTool
tool_registry.register(WeatherTool())Guidelines:
- Keep
execute()async — useasyncio.get_event_loop().run_in_executor()for blocking work - Return JSON-serializable types
- Keep
descriptionconcise — it goes into the LLM's context window - Raise exceptions on failure; the loop catches them and reports an error trace event
Code Style
TypeScript / JavaScript
- Fat arrow functions:
const foo = () => {}— notfunction foo() {} - Named exports:
export const Foo = ...— default exports only for Expo Router page files (framework requirement) - Hooks:
export const useXxx = (...) => { ... } - React components:
export const MyComponent = (props: Props) => { ... }
Prettier (.prettierrc)
{
"printWidth": 60,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"semi": false,
"arrowParens": "always"
}Run: npx prettier --write "apps/**/*.{ts,tsx}"
Python (Ruff)
Configured in apps/agent/pyproject.toml. Line length 88, double quotes, Black-compatible.
# from apps/agent/
.venv/bin/ruff format src/ # format
.venv/bin/ruff check src/ # lint
.venv/bin/ruff check --fix src/ # lint + auto-fixDoes it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

