agent-httpd: Taking AI All the Way Down to C

🇨🇳 中文版

In one sentence: agent-httpd is a bottom-of-the-stack programming experiment — I wanted to see how far down AI could actually write code. It started in TypeScript / Python, reached C, and then brushed against assembly and SIMD; the vehicle for that experiment is a pure-C HTTP/1.1 server.

Intro: Can AI write C?

Getting AI to write React, Node.js, or Python is no longer remarkable. I was more curious about the other end:

Can it write C?

So I didn't stop at a demo — I took on a real project: write an HTTP server. That project grew into agent-httpd.

Once the C was in place, I asked a follow-up: is C the bottom? So I pushed further, into assembly and SIMD. Below that, there is still bytecode.

What this article records is not "how strong AI is", but — after pushing it down layer by layer, where does it start to struggle?

A formal definition first:

agent-httpd is a pure-C HTTP/1.1 server: a single-process event loop (kqueue / epoll) plus a prefork worker pool split the fast and slow paths, with CGI / FastCGI, SSE, React SSR, and a native C LLM agent stack that depends on no Python / JS runtime.

Let me state my own position first: I have some C, socket, and CGI experience and I write Node.js day to day, but I never studied prefork, epoll, or SCM_RIGHTS systematically — I worked them out while building this. So this is not a bottom-layer expert writing a retrospective; it is an exploration of AI plus engineering practice: I set the requirements, judge the direction, and verify the result, while AI writes most of the code.

Let me also draw the boundary: "pure C" refers to the server core — TLS is delegated to a forked curl(1), JSON is hand-parsed, and MCP formatting borrows jq(1). Pulling in neither OpenSSL nor a JSON library is a deliberate project convention.

The experiment: pushing AI from the application layer downward

Stage 1: Let AI write C

Building a C HTTP server touches quite a lot:

  • socket programming and the accept loop;
  • an epoll (Linux) / kqueue (macOS) event loop;
  • the prefork process model and inter-process fd passing (SCM_RIGHTS);
  • CGI: fork + pipe + execl;
  • isolating the slow path and controlling concurrency.

AI can generate all of it. The real work lives elsewhere:

AI writes most of the code; I define the requirements, run it, read the errors, narrow the problem down, and verify the result.

This is not so different from how I normally write application code — except the "language" is C this time, and the feedback is harsher. Segfaults, fd leaks, and logical races only surface when you actually run it.

Stage 2: Further down — assembly and SIMD

Once the C flowed, the next question was obvious: can we go lower?

I kept a set of experiments in the project (asm-demo/): the hand-written .c sources and a Makefile are committed, but the .s assembly dumps produced by clang -S and the compiled Mach-O binaries stay local. Built around real performance-sensitive primitives in the server: CRLF scanning of HTTP headers, single-byte search in a buffer (the shape of memchr), and html_escape. Each primitive gets several implementations, compiled to assembly with clang -S, then measured.

Take single-byte search: three implementations — a scalar byte loop, a direct call to libc memchr, and hand-written portable SIMD (aarch64 NEON / x86_64 SSE2).

/* scalar: same shape as the code in http.c */
for (size_t i = 0; i < n; i++)
    if (buf[i] == '\n') return (long)i;

/* hand-written NEON: compare 16 bytes at a time, locate the index scalar-wise on a hit */
uint8x16_t vn = vdupq_n_u8('\n');
for (; i + 16 <= n; i += 16) {
    uint8x16_t eq = vceqq_u8(vld1q_u8((const uint8_t *)buf + i), vn);
    if (vmaxvq_u8(eq)) { /* hit in this block: locate the index scalar-wise */ }
}

Compiled down, you can see the loop the compiler generates for the scalar version, and the cmeq / umaxvq / ldr q instructions behind the hand-written SIMD. The observations are direct:

  • AI can write SIMD intrinsics like these — it compiles, it runs, and it can keep fixing things from the errors;
  • but the code volume and maintenance cost clearly rise — a few lines of scalar loop unfold into a version with boundary handling and alignment assumptions;
  • and it is no free lunch: hand-written SIMD reliably beats the scalar loop, yet often loses to the libc implementations that have been tuned for years.

The most instructive case was html_escape. I assumed the bottleneck was "finding the special characters", so SIMD should help. The data said no:

The real bottleneck was the snprintf in the loop, not the byte scan.

SIMD optimizes the latter, so it bought almost nothing; instead, the pure scalar version that replaced snprintf with a direct memcpy of the replacement string won. In other words, before you have located the real bottleneck, "reach for SIMD / assembly" is very likely optimizing the wrong thing.

There is also a more mundane conclusion: agent-httpd caps request bodies at 64KB and is I/O-bound overall, so in real workloads these primitives buy almost nothing in absolute terms. The value of this layer is less "go faster" and more "know where the boundary is".

One layer deeper: bytecode — this is where I stopped

Could AI generate bytecode directly?

An LLM's output is, at bottom, a token sequence. It can generate a representation of bytecode as text or hex, but that is not the same as "directly emitting a constrained, verified, executable slice of VM bytecode" — what is missing in the middle is constraint and verification, not expressiveness.

In theory I could keep going down, but in a workflow where LLM-generated code does most of the writing, bytecode is not as natural a target as C or assembly: with C and assembly you at least have a compiler and a CPU to verify you, whereas at the bytecode layer that constraint layer is yours to build. So this is where I stopped. C remains a comfortable middle layer: close enough to the metal, without the tedium of assembly.

How agent-httpd works

The whole picture first:

                    agent-httpd
                         │
              ┌──────────┴──────────┐
              │                     │
           Master                 Worker
              │                     │
        epoll / kqueue          CGI / SSE
              │                     │
        Fast / Slow Path         Agent
                                    │
                         ┌──────────┼──────────┐
                         │          │          │
                        LLM        MCP       Skills

Now, piece by piece.

HTTP / process: the master accepts, slow work is dispatched

This is the engine. It comes down to two things: epoll (Linux) / kqueue (macOS) plus recv(MSG_PEEK).

On each new connection, the master uses recv(MSG_PEEK) to preview the request header — classifying the request without consuming socket data:

recv(fd, buf, sizeof(buf), MSG_PEEK)

Based on that preview, requests split into two paths:

  • Fast path: static files, health, 304 / 301 / 404, body-less OPTIONS — served directly by the master, no fork.
  • Slow path: CGI, chat SSE, upstream proxy — the connection fd is handed to the prefork worker pool via SCM_RIGHTS, and a worker takes over.
sendmsg(fd, &msghdr_with_SCM_RIGHTS, 0);  // hand the fd to a worker

Two things are worth spelling out:

  • The master keeps slow paths and potentially blocking operations isolated, dispatching that work to workers — so those slow jobs do not interfere with the master's connection dispatch, and the fast path stays predictable under load.
  • When the fd is passed via SCM_RIGHTS, the header bytes are still in the kernel buffer, so the worker can re-read them exactly as if it had accepted the connection itself — no byte replay from the main loop to the worker is needed.

Eight workers by default (-w to resize; 0 falls back to the classic fork-per-connection model).

One layer that fell out of it: libagenthttpd

At some point I realised these capabilities should not belong to a single binary. So the HTTP dispatch, CGI / FastCGI, SSE, and agent-tool layers were extracted into a static library, bin/libagenthttpd.a (make lib): link it, register your own routes and agent tools, and you get an HTTP server with a built-in agent running inside your own process. The CLI binary is just a thin front end over the same API.

#include "agenthttpd.h"

agenthttpd_route("GET", "/api/status", my_status_handler);
agenthttpd_tool_exec("wordcount", "...", params_json, "python3 tools/wordcount.py");
agenthttpd_run(&(agenthttpd_config){ .port = 18101, .workers = 4 });

The public API is deliberately tiny (four functions and one struct, in src/agenthttpd.h). One constraint is worth recording: registration must happen before agenthttpd_run() — the tool table and routes are initialised before the fork, and workers inherit a frozen copy, so registering after run() is neither visible nor race-free.

On maturity, to be clear: this is an extraction of existing capabilities into a reusable C layer, not a mature SDK, and deliberately not a general-purpose C web framework (libmicrohttpd / mongoose / drogon already hold that ground). make example-run brings examples/embedded up on :18101; the full trade-off record is in docs/FRAMEWORK.md.

Dynamic execution: one binary runs 7 languages of CGI

agent-httpd runs CGI in bash / Python / Go / Rust / Java / PHP / Ruby at once, built on the classic fork + pipe + execl (src/cgi/cgi.c): fork an isolated child, pass the request body and CGI environment through a pipe, and execl the child into the right interpreter or executable.

The standard CGI variables are all in place, per RFC 3875: REQUEST_METHOD, QUERY_STRING, CONTENT_LENGTH, GATEWAY_INTERFACE, SERVER_SOFTWARE, REMOTE_ADDR.

Each language runs in its own process, isolated from the others. Startup cost differs clearly between languages (interpreter caching, compiled artifacts, runtime warmup); for long-lived scenarios you can switch to FastCGI mode (both client and server) to reuse processes instead of starting one per request.

AI: an LLM agent running inside C

agent-httpd embeds a pure-C LLM chat agent at /react/api/chat. No PyTorch, no LangChain, no separate Node service — a single C binary carries both the ReAct loop and SSE streaming (src/agent/llm.c).

The most pragmatic move is handing TLS to curl: a forked curl child handles the handshake and the HTTP request, so the C code never implements TLS itself.

fork(); execl("curl", "curl", "--data", ..., NULL);  // curl handles TLS + HTTP

The agent core is a ReAct loop: the model generates a thought → calls a tool → gets the result → feeds it back → generates again. Tool calling supports MCP (Model Context Protocol) and built-in skills, and the whole loop runs in C. No API key? A built-in demo engine streams output with zero credentials.

Frontend: the React SSR pipeline

agent-httpd embeds a complete React SSR build pipeline driven by scripts/build-ssr.sh: TypeScript + Tailwind → esbuild bundle → precompiled SSR bundle. The rendering core in cgi-bin/react-ssr/server/render.tsx is shared between CGI mode and resident backend mode, for consistent output.

The -v dev mode tunnels the Vite dev server's proxy and HMR WebSocket to the same port, giving a near-pure-frontend development experience.

There is one key caching design: static responses carry Cache-Control: no-cache, CGI / SSR documents carry no-store; and bundle URLs carry a hash (/js/react-ssr.js?v=<hash>), so when the bytes change the URL changes and the browser is forced to re-fetch — no reliance on cache directives to "convince" it.

Performance: design goals and measurements, not slogans

I don't want to shout slogans here.

The design goal first: isolate slow requests from the main event loop, so the fast path stays predictable under pressure. That is a structural tradeoff, not a "never blocks" guarantee — without a strict proof, I am not going to write it that way.

Then the measurements. The project ships a load script, scripts/bench.py, comparing two client strategies (keep-alive, per-conn) and printing req/s with p50 / p90 / p99. Under that script, the default "master + worker pool" shows a several-fold per-conn throughput gap over the old fork-per-connection model.

Two caveats: first, C's speed is not worth arguing about — the point here was never "C is fast" but the scheduling structure (move the slow work out, keep the fast path clean); second, a single-machine script only shows the structure works, and is not a product-grade performance claim.

A fuller wrk / hey / ab run, multiple worker-count tiers, and memory footprint are still not done. I'll add them once I have the data.

A few details that are easy to misread

For a project like this, the wording has to hold up to a technical reader. Several phrasings I deliberately changed:

  • MSG_PEEK: recv(MSG_PEEK) looks at the bytes sitting in the socket buffer without consuming them — request classification is built on that preview.
  • "never blocks": rephrased as "the master keeps slow paths and potentially blocking operations isolated", so they do not affect connection dispatch.
  • "high performance": without a benchmark to back it, stated only as "designed around a low-overhead event-driven model".
  • "pure C": means the server core is C; some external capabilities (TLS, MCP formatting) come in through forked subprocesses and external tools (curl, jq).

Security: verifying the real attack surface

This is an experimental project, so I also went through the attack surface that gets targeted in practice. One clarification up front: what follows is which defences are actually implemented — it does not mean the project meets production-grade security standards, which would take independent audit, sustained adversarial testing, and load testing, not something one person finishes inside one project.

  • Path traversal: every static path is validated with realpath(), so .. cannot escape the docroot (static.c / framework.c).

  • Per-IP rate limiting: src/security/ratelimit.c uses a token bucket in MAP_SHARED anonymous memory; each IP hashes into a fixed bucket via FNV-1a, and the bucket is serialized with a process-shared mutex (PTHREAD_PROCESS_SHARED) — fork-per-connection and worker-pool modes enforce the same quota, returning 429 when exhausted.

    pthread_mutex_lock(&bucket.lock);
    bucket.tokens -= 1;   /* admit only if a token is acquired */
    pthread_mutex_unlock(&bucket.lock);
    
  • Strong hashing for Basic Auth: only crypt(3) SHA-256 ($5$) / SHA-512 ($6$) or bcrypt are accepted; plaintext, DES, $1$ MD5-crypt, and $apr1$ are rejected by default (only the AGENTHTTPD_ALLOW_WEAK_AUTH dev switch can admit weak hashes).

  • CSRF: the state-changing LLM endpoints (/react/api/chat, /react/api/pse) enforce a same-origin check and require POST. More fundamentally, read methods like GET / HEAD / OPTIONS are designed to never modify state — separating "read" from "write" at the root.

Current limitations

Writing the boundaries down clearly is more useful than listing capabilities:

  • Not equivalent to a production-grade web server; the positioning is a single experiment.
  • CGI has process-startup cost (switch to FastCGI only for long-lived scenarios).
  • Agent tool permissions still need finer isolation.
  • TLS depends on an external capability (a forked curl), not a built-in implementation.
  • No complete benchmark yet.
  • No mature sandbox yet.

What I actually tested

Rather than a "supports X" list, I'd rather list what I actually ran:

✓ Static files / directory listing / ETag 304
✓ CGI (multiple languages)
✓ Slow-path worker pool + SCM_RIGHTS fd passing
✓ SSE streaming
✓ React SSR
✓ LLM Chat (including the keyless demo engine)
✓ MCP tool calling
✓ asm-demo primitives (scalar / SIMD / libc comparison)

Each item has a matching command and note in the README and docs/ARCHITECTURE.md.

Closing: a boundary

This experiment gave me a fairly direct view of a boundary:

AI writing C can already take part in complex systems engineering; going further into assembly / SIMD is not impossible either, but code complexity and maintenance cost rise quickly.

And when optimizing at the bottom, the first move is not "reach for SIMD" but finding the real bottleneck with data — in the html_escape case, the bottleneck was not where I assumed.

Looking back, my role in this was quite plain: this was not a from-scratch, hand-written tour of low-level code, but an experiment in which AI walked me down the stack. How far down can AI write, and at which layer do the real problems start to appear — I do not have a complete answer yet, but I do have a few concrete coordinates.

C remains a very interesting middle layer: close enough to the metal, without the tedium of assembly. As for bytecode further down, I did not continue this time.

So to me, agent-httpd is not just an HTTP server.

It is more like a testbed: pushing AI ever lower, to see how far it can actually go.

Why choose a prefork architecture over multithreading or single-process async?

Prefork runs each worker as an independent process, isolating the execution state of different requests and reducing thread-level synchronisation issues in shared-memory scenarios. The master accepts connections and passes fds to workers via SCM_RIGHTS for parallel processing across processes — balancing structure and implementation complexity in a C project.

Why does the CGI module use fork + pipe + execl?

It is the classic CGI model — fork an isolated child, pass the request body and response over a pipe, and execl the child into the target language interpreter. Each language runs in its own independent process without interfering with the others; long-lived scenarios can switch to FastCGI to avoid re-forking per request.

How is rate limiting coordinated across workers?

The token bucket lives in MAP_SHARED anonymous memory; each IP hashes into a fixed bucket via FNV-1a, and the bucket is serialized with a process-shared mutex (PTHREAD_PROCESS_SHARED). Both fork-per-connection and worker-pool modes enforce the same quota, returning 429 when exhausted.

What exactly does "AI writes assembly" mean here?

Precisely, it is not AI hand-writing assembly, but AI writing C and SIMD intrinsics, then compiling them to assembly with clang -S for comparison. The experiment (asm-demo/) concludes that hand-written SIMD beats the scalar loop but often loses to libc’s tuned implementations; bottom-level optimization should locate the real bottleneck first rather than assuming “jump to SIMD”.

What hard security policies are in place?

Password storage accepts only crypt(3) SHA-256/SHA-512 or bcrypt; plaintext and MD5-class weak hashes are rejected by default (a dev switch can admit them). For CSRF, state-changing LLM endpoints enforce same-origin + POST, while read methods like GET/HEAD/OPTIONS are designed to never modify server state.

Why does React SSR caching use a hash instead of browser cache directives?

Bundle filenames carry a hash (e.g., /js/react-ssr.js?v=). When the content changes, the hash changes and forces the browser to re-fetch, without relying on Cache-Control to persuade the browser to refresh. Meanwhile, responses are marked no-cache/no-store to keep content fresh.

How do I debug during development?

Use -v mode. The Vite dev server’s proxy and HMR WebSocket are both tunneled to the same port, giving a development experience close to a pure frontend project.


Project repository: github.com/erishen/agent-httpd

If you are also curious how far down AI can write systems code, go have a look at the source.

Comments

Leave a reply

Your email address will not be published. Required fields are marked *

AI Engineering Practices & Open Source Projects

Shop Web Chat Nsbp About Privacy

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号