Teaching an AI to know itself: Building a local LLM agent in D Danny Arends, a long-time D language developer, has built DLLM, a minimal coding agent in D that runs directly on llama.cpp without Python or bindings, using D's ImportC feature to access the C API with zero FFI overhead. The project features a @Tool UDA registration system that automatically creates tool schemas from function signatures, and grammar-constrained sampling for structured output. Arends says the Python LLM ecosystem is enormous but layers of abstraction make it hard to debug, so he wanted to understand what was actually happening. Teaching an AI to Know Itself: Building a Local LLM Agent in D Jun 7, 2026 • Danny Arends • Community /search?q=Community , Guest Posts /search?q=Guest+Posts , Project Highlights /search?q=Project+Highlights , Tutorials /search?q=Tutorials , Code /search?q=Code , Machine Learning /search?q=Machine+Learning I’ve been writing D for a long time. DaNode https://github.com/DannyArends/DaNode , my self-contained web server, has been running in production for over 12 years. DImGui https://github.com/DannyArends/DImGui is a full SDL + Vulkan renderer that supports skeletal animations via the Open Asset Import Library, HDR lighting, and compute shaders, written entirely in D calling into external libraries via ImportC . So when I decided to build a local agentic large language model LLM DLLM https://github.com/DannyArends/DLLM from scratch, I’d sooner write it in Brainfuck than reach for Python. To be fair, the Python LLM ecosystem is enormous. However, by the time you have a working agent, you’re sitting on top of a framework, which wraps a library, which calls into C++ via ctypes, which dispatches to CUDA kernels. Python all the way down to the metal, with several layers of abstraction you didn’t write and can’t easily debug. I wanted to understand what was actually happening. DLLM https://github.com/DannyArends/DLLM is my latest D project: a minimal, clean coding agent built directly on llama.cpp. No Python, no bindings, no overhead. Here’s a walkthrough of the two parts I’m most happy with: the @Tool UDA registration system, and grammar-constrained sampling. Starting Point: ImportC Before anything else, the foundation. D’s ImportC lets you include C headers and use the API directly, as native D code. DLLM has one file, includes.c , that pulls in the llama.cpp and mtmd headers. From there, llama decode , llama model load from file , llama sampler sample , the whole llama.cpp API, is available in D with full type safety and zero FFI overhead. This is the same trick I used in DaNode to wrap OpenSSL, and an integral part of DImGui to call into Vulkan, SDL, the Open Asset Import Library, and shaderC. ImportC is one of my favorite D features. I used to rely heavily on the Derelict & BindBC wrappers, and they were fantastic community contributions, but ImportC has made them almost obsolete. No wrapper libraries, no binding maintenance, no surprises when the upstream C API updates. The Tool System: Start With a Single UDA An LLM agent is only useful if it can act . DLLM’s tools cover web search, file I/O, Docker-sandboxed code execution, image download, date and time, text encoding, and audio playback. To act , it needs tools that it can control, and in DLLM you can create a new tool that the agent can use like this: @Tool "Count how many times substring appears in text." string nOccurrences string text, string substring { try { return to string text.count substring ; } catch Exception e { return format "Error: %s", e.msg ; } } The @Tool ... attribute is the entire registration step. No schema file to maintain, no separate dispatch table. The Tool struct itself is trivial: struct Tool { string description; } One string. That’s the whole UDA definition. Everything else is derived from it and the function signature automatically. The description string is also used by the LLM agent to figure out what the tool is able to do. Building Up: RegisterTools At the top of each tool module, there’s one line: mixin RegisterTools; This is a mixin template that injects a static this module constructor. When the program starts, that constructor runs and populates a global tool definition array ToolDef called ALL TOOLS . Here’s how it works, step by step. First, it gets a reference to the current module using the MODULE string mixin trick: mixin "alias ThisModule = " ~ MODULE ~ ";" ; Then it loops over every symbol in that module using traits allMembers, ... and static foreach : static foreach name; traits allMembers, ThisModule {{ mixin "alias member = " ~ name ~ ";" ; static if is typeof member == function { static if hasUDA member, Tool { For each function that has a @Tool attribute, it extracts the description and the parameter names: enum description = getUDAs member, Tool 0 .description; alias ParamNames = ParameterIdentifierTuple member; ParameterIdentifierTuple is a standard D trait that gives you the parameter names as a compile-time tuple: For nOccurrences string text, string substring that’s "text", "substring" . Then it builds an executor closure that unpacks the JSON arguments and calls the function: auto executor = JSONValue args { string argValues; static foreach paramName; ParamNames { argValues ~= args paramName .type == JSONType.string ? args paramName .str : args paramName .toString ; } // mixin generates: return member argValues 0 , argValues 1 ; mixin callStr ; }; ALL TOOLS ~= ToolDef name, description, parameters, executor ; So after startup, ALL TOOLS , the global tool definition array contains everything needed to both describe each tool to the LLM agent and allow it to be called by name at runtime. The function signature is the single source of truth. What Gets Generated: System Prompt and Grammar From ALL TOOLS , two things are auto-magically generated. First, toolsToJSON generates the JSON that goes into the system prompt, so the model knows what tools exist and what they can do: { "name": "nOccurrences", "description": "Count how many times substring appears in text.", "parameters": { "type": "object", "properties": { "text": {"type": "string"}, "substring": {"type": "string"} } } } Second, buildJsonGrammar generates a GBNF grammar https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md for constrained sampling. A GBNF grammar is a set of rules that define exactly what sequence of tokens text is valid. A simple example for a yes/no answer would look like: root ::= "yes" | "no" That’s it, the sampler can now only produce the word “yes” or “no”, nothing else. For DLLM’s tool calls, the grammar is more complex but the principle is identical. The toolname rule is generated dynamically from ALL TOOLS , so only real tool names are valid. Everything else follows standard JSON structure rules. Unlike many Python-based agent frameworks, which handle tool calls with prompt engineering, output parsing, and prayer, grammar-constrained sampling gives an iron-clad guarantee that every tool call is structurally valid. The full GBNF grammar definition of valid JSON toolcalls is: js string buildJsonGrammar { auto names = ALL TOOLS.map t = "\"\\\"" ~ t.name ~ "\\\"\"" .join " | " ; return root ::= "{" ws "\"name\"" ws ":" ws toolname ws "," ws "\"arguments\"" ws ":" ws object ws "}