guile-sage: Building an AI Agent in Scheme
Table of Contents
Status: under active development. This is a build log, not a finished report.
Source: github.com/dsp-dr/guile-sage
1. What it is
guile-sage is a multi-provider AI agent REPL written in GNU Guile 3 Scheme. ~8800 lines of Scheme across 20 modules, 22+ built-in tools, MCP client support, and OTLP telemetry. It runs on FreeBSD and macOS.
The interesting question is not "why build another agent CLI" (there are six of those, see CLI Coding Agents comparison) but "what does building one in Scheme teach you about agent design that you can't learn by using one?"
2. Why Scheme
Three properties of Scheme that matter for agent internals:
- Homoiconicity. Tool definitions, conversation history, and configuration are all s-expressions. The agent can inspect and manipulate its own tool registry the same way it manipulates data. No serialization boundary between "code" and "config."
- Continuations. Multi-step tool chains with error recovery map naturally to continuation-passing. The degenerate-loop detector is a continuation guard, not a counter.
- Minimal runtime. Guile 3 on FreeBSD is ~20 MB. No nodemodules, no pip, no cargo. The agent's dependency surface is the OS + Guile + curl.
3. Architecture
3.1. Module map
| Module | LOC | Purpose |
|---|---|---|
agent.scm |
~600 | Core agent loop, tool dispatch |
tools.scm |
~900 | Tool registry, fetchurl, file ops |
repl.scm |
~500 | Interactive REPL, slash commands |
session.scm |
~400 | Persistence, context management |
compaction.scm |
~300 | 5 compaction strategies |
provenance.scm |
~200 | Content hashing, fetch wrapping |
mcp.scm |
~300 | MCP client (SSE transport) |
ollama.scm |
~400 | Ollama provider |
gemini.scm |
~300 | Gemini provider |
openai.scm |
~200 | OpenAI-compatible provider |
telemetry.scm |
~200 | OTLP metrics emission |
commands.scm |
~400 | Slash-command dispatch |
3.2. The FreeBSD portability story
Two hard bugs shaped the architecture:
- Guile 3.0.11 on macOS:
system*/open-pipe*corrupt file descriptors. The fix:capture-argv-in-dir—primitive-fork+execlpin a temp directory. Every external process invocation (curl, git, shell tools) goes through this helper. The bug is in Guile's C-level fd handling, not in Scheme. - FreeBSD
popensegfaults under Guile. Same root cause, different symptom. Same fix.
The result: the agent never calls system or open-pipe. All
subprocess invocation goes through a single helper that forks
explicitly. Ugly, portable, tested.
4. The fetchurl design
The most instructive module for agent design is the HTTP fetch tool. Three iterations:
4.1. v1: strip-script-tags (cargo cult)
The first version fetched HTML, stripped <script> tags, and wrapped
the body in comment markers. This was wrong:
- Lossy: you can't answer "what scripts does this page load?"
- Arms race:
<script>is one vector among hundreds - False signal: a clean body implies "safe" when it's partially sanitized
4.2. v2: CDATA wrapping (correct)
The current version wraps the entire response body in a
<fetch-result> envelope with CDATA, preserving the content
byte-for-byte:
<fetch-result source="https://example.com/" content-type="text/markdown" http-code="200" bytes="6795" sha256="227a864e..." fetched-at="2026-04-19T19:11:49Z" trust="untrusted" user-agent="guile-sage/0.1 (...)"> <![CDATA[ ...body... ]]></fetch-result>
The key properties:
- Framing, not sanitization. The consumer decides policy.
- Provenance. SHA-256, timestamp, UA, HTTP code, content-type.
trust"untrusted"=. Explicit signal to the model.- Content negotiation.
Accept: text/markdownpreference means sites that support it (like wal.sh) return markdown — smaller, no tags to worry about.
4.3. The injection test
We built a live test page at
guile-sage-inject with an
XML-escape + authority-spoofing + silence-instruction payload.
Result: the role boundary held. The model saw the beacon URL and
didn't act on it. Defence came from framing (<tool-result>
wrapping), not from stripping.
Server-side verification: gmake logs + grep for the canary query
parameter. Zero hits = pass.
5. Content negotiation as a design axis
When the fetch tool sends Accept: text/markdown, sites that support
the Cloudflare "Markdown for Agents" pattern return markdown instead
of HTML. This changes the agent's cost structure:
- ~55% smaller responses (no HTML boilerplate)
- No script/style tags to worry about
- Content is already in the format the model prefers
- SHA-256 still verifies the body
wal.sh serves markdown for all 470+ pages via an ox-md publish
component + Apache mod_rewrite content negotiation. See
Cloudflare Agents Week
for the standards context.
6. What Scheme teaches you
Building an agent in Scheme surfaces design questions that higher-level frameworks hide:
- The tool registry is data. In Scheme it's an alist. You can
filter,map,foldover your tools at runtime. Adding a tool iscons. Removing one isremove. No reflection API needed. - Conversation history is a list. Compaction is
take. Context islength. Branching isconson a copy. The data structure IS the abstraction. - Error recovery is continuation-based. A tool failure doesn't crash the agent loop; it invokes a continuation that the loop installed. The degenerate-loop detector is a guard on the continuation, not a global counter.
- Provenance is cheap when your data is immutable. The SHA-256 of a fetch result never changes because strings are immutable. No defensive copies.
7. Open questions
- Can the MCP client (SSE transport) handle reconnection gracefully, or does a dropped connection require a fresh session?
- Is 8K context (llama3.2) viable for fetch + summarize, or is the minimum practical window 32K?
- How does the degenerate-loop detector interact with legitimate multi-step tool chains (e.g., read → edit → test → commit)?
8. Related
- guile-sage injection testing — the live canary page and payload
- sage.el — the Emacs companion project
- CLI Coding Agents comparison — where guile-sage sits relative to Claude Code, Copilot CLI, etc.
- Scheme — the language research note
- Cloudflare Agents Week — content negotiation standards context
