montyanderson/lambda

extremely fast portable agent harness in c

11

stars

5

commits

C

primary language

Sep 2, 2026

updated

README

λ lambda

extremely fast portable agent harness in c.

lambda tui
  • fixed-frame tui, scrolls internally, basic markdown
  • portable and statically linked
  • minimal dynamic memory allocation
  • single-file c plugins (exa web search included)
  • every chat saved as resumable ndjson

build

make            # → ./lambda
make release    # dead-stripped, symbols removed (~243 kb)
make install    # → $(PREFIX)/bin, default /usr/local
make uninstall
make test       # unit tests under asan/ubsan
make uitest     # terminal render + scroll checks (needs python + pyte)
make STATIC=1   # static binary (best with musl-gcc)
make DEBUG=1    # asan/ubsan

c99 compiler and gnu make. nothing else.

install honours PREFIX, BINDIR, DESTDIR.

use

export ANTHROPIC_API_KEY=sk-ant-...

lambda                      # tui
lambda -p "explain memfd"   # one-shot
echo "prompt" | lambda      # one-shot from stdin
lambda resume               # continue the last chat
lambda resume FILE          # continue a specific one
flag
-m MODELmodel id (default claude-fable-5)
-s SYSTEMsystem prompt
-p PROMPTone-shot, then exit
-e EFFORTlowmax reasoning effort
-r FILE / -csame as resume FILE / resume
--no-thinkinghide reasoning
--no-toolsdisable bash
--no-logdon't record
--no-contextignore AGENTS.md / CLAUDE.md
--no-fallbackdisable refusal fallbacks

commands: /model, /system, /effort, /thinking, /tools, /clear, /help, /quit.

keys: enter sends. pgup/pgdn or wheel scroll. ctrl-c interrupts a reply or a running command. ctrl-d quits. ctrl-a/e/k/u/w edit, up/down for history.

the input stays live while the model works — enter queues instead of interrupting. queued prompts show above the input marked » and run in order.

piped or redirected it drops the tui and streams plain text to stdout. NO_COLOR disables styling there.

model

default is claude fable 5, with display: "summarized" so reasoning streams live in dim italic under a gutter. --no-thinking hides it.

-e sets output_config.effort. unset means the api default; xhigh for long agentic work, low for cheap turns.

fable costs ~2x the opus tier ($10/$50 vs $5/$25 per mtok) and needs 30-day data retention — not available under zdr. -m claude-opus-5 switches back. thinking/effort params are only sent to models that accept them, so older ids like claude-haiku-4-5 still work.

refusal fallbacks are on by default for fable/opus-5: if a classifier declines, the api retries on a fallback model in the same call and the status line notes the switch. --no-fallback disables.

context files

walks from cwd up to / collecting AGENTS.md and CLAUDE.md, and puts them at the head of the system prompt with a cache_control breakpoint. nearest file wins. loaded paths print on the first line.

transcripts

./.lambda/chats/<timestamp>.jsonl, one json object per line, written as the chat happens:

{"t":"session","v":1,"time":"…","model":"claude-fable-5","cwd":"…"}
{"t":"msg","m":{"role":"user","content":"…"}}
{"t":"drop","n":1}
{"t":"meta","k":"model","v":"claude-opus-5"}

msg holds the api message verbatim — text, thinking blocks with signatures, tool_use, tool_result — so a resume is byte-identical to what the model saw. a failed or interrupted turn rolls back and writes drop, so resume never replays a turn that didn't happen.

writes are queued in memory and flushed only when idle, so they never block the ui or a request. survives kill -9.

bash tool

runs /bin/sh -c in its own process group from the cwd. stdout+stderr merged, truncated at 64 kb, non-zero exit reported to the model. no state persists between calls. ctrl-c signals the whole group.

no approval prompt — if claude decides to run something, it runs. don't point it at a directory you'd mind it changing. --no-tools disables.

plugins

one .c file in plugins/. the makefile globs the dir and each file registers itself at startup — no registry, no codegen.

#include "plugin.h"

static int run(const char *args_json, buf *out)
{
    buf_appends(out, "pong");
    return 0;
}

static const lambda_tool ping = {
    .name = "ping",
    .description = "reply with pong",
    .schema = "{\"type\":\"object\",\"properties\":{}}",
    .run = run,
};

LAMBDA_TOOL_REGISTER(ping)

run gets the raw input json (parse with jsonx.h) and writes the result into out. optional: available() hides the tool when e.g. a key is missing, label() sets the transcript line. plugin_https_post() reuses lambda's tls.

exa

plugins/exa.c adds exa_search. set EXA_API_KEY to enable; without it the tool isn't offered. returns highlights by default (cheap in tokens); full_text for whole pages. num_results 1–25, plus type and category.

no malloc

every buffer is a static arena sized in src/config.h. buffers report truncation instead of growing; on overflow the turn rolls back and says so. jsmn, picohttpparser and bearssl are allocation-free too.

measured, not assumed: under an LD_PRELOAD interposer, a full session attributes zero allocations to lambda. the ~500 the process makes are glibc's, from getaddrinfo and stdio. objdump -R shows no allocator import.

bss is ~22 mb of arenas but demand-paged, so startup stays ~1 ms.

tls

bearssl has no os trust store, so root cas load from the first of: $SSL_CERT_FILE, /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, /etc/ssl/ca-bundle.pem, /etc/ssl/cert.pem. parsed lazily on first request.

layout

src/
  main.c    args, repl, slash commands
  api.c     messages api, sse parsing, tool loop
  http.c    https client on bearssl + picohttpparser
  ta.c      pem bundle → bearssl trust anchors
  tools.c   bash tool + dispatch
  plugin.c  plugin registry, shared https helper
  project.c AGENTS.md / CLAUDE.md discovery
  session.c ndjson transcripts, resume
  ui.c      transcript, wrapping, layout, line editor
  term.c    raw mode, alt screen, diffed cells, key decoding
  md.c      inline markdown → style flags
  jsonx.c   jsmn helpers
  util.c    fixed-capacity buffers
  config.h  all arena sizes
plugins/exa.c
vendor/     bearssl 0.6, jsmn, picohttpparser (all MIT)

the cell grid tracks display width, not codepoints — cjk and emoji take two columns, so a wide glyph claims a continuation cell. without it the grid and terminal disagree and stale glyphs linger when scrolling.

no termbox/curses: lambda uses ~5% of either, and termbox2 keeps pointers into arrays it reallocs. raw mode + diffed grid + key decoding is ~450 lines.

tests

make test builds tests/ under asan and ubsan with a deliberately tiny transcript arena, so compaction and eviction are exercised in a short run rather than only after hours of use.

make uitest covers the two display bugs that have actually shipped. it replays term.c's own escape output through an independent terminal emulator and checks the result matches term.c's back buffer — drift there is what leaves stale glyphs, usually via wide characters. it then fires bursts of scroll events faster than the repaint coalescing window and checks the settled screen equals a forced full repaint, which catches a deferred frame being stranded. LAMBDA_SELFTEST_FILL=n seeds the transcript so this runs with no api key.

ci compiles with -Werror on gcc and clang across linux and macos, runs both suites, and checks the static link.

limits

  • width is per codepoint, so grapheme clusters (flag emoji, skin tones) sit a column off
  • fresh dns/tcp/tls per turn (Connection: close) — a few hundred ms before the first token; keep-alive would fix it
  • glibc static builds still dlopen nss for dns; use musl for a truly self-contained binary

Contributors

montyanderson

5 commits

montyanderson/lambda

extremely fast portable agent harness in c

11

stars

5

commits

C

primary language

Sep 2, 2026

updated

README

λ lambda

extremely fast portable agent harness in c.

lambda tui
  • fixed-frame tui, scrolls internally, basic markdown
  • portable and statically linked
  • minimal dynamic memory allocation
  • single-file c plugins (exa web search included)
  • every chat saved as resumable ndjson

build

make            # → ./lambda
make release    # dead-stripped, symbols removed (~243 kb)
make install    # → $(PREFIX)/bin, default /usr/local
make uninstall
make test       # unit tests under asan/ubsan
make uitest     # terminal render + scroll checks (needs python + pyte)
make STATIC=1   # static binary (best with musl-gcc)
make DEBUG=1    # asan/ubsan

c99 compiler and gnu make. nothing else.

install honours PREFIX, BINDIR, DESTDIR.

use

export ANTHROPIC_API_KEY=sk-ant-...

lambda                      # tui
lambda -p "explain memfd"   # one-shot
echo "prompt" | lambda      # one-shot from stdin
lambda resume               # continue the last chat
lambda resume FILE          # continue a specific one
flag
-m MODELmodel id (default claude-fable-5)
-s SYSTEMsystem prompt
-p PROMPTone-shot, then exit
-e EFFORTlowmax reasoning effort
-r FILE / -csame as resume FILE / resume
--no-thinkinghide reasoning
--no-toolsdisable bash
--no-logdon't record
--no-contextignore AGENTS.md / CLAUDE.md
--no-fallbackdisable refusal fallbacks

commands: /model, /system, /effort, /thinking, /tools, /clear, /help, /quit.

keys: enter sends. pgup/pgdn or wheel scroll. ctrl-c interrupts a reply or a running command. ctrl-d quits. ctrl-a/e/k/u/w edit, up/down for history.

the input stays live while the model works — enter queues instead of interrupting. queued prompts show above the input marked » and run in order.

piped or redirected it drops the tui and streams plain text to stdout. NO_COLOR disables styling there.

model

default is claude fable 5, with display: "summarized" so reasoning streams live in dim italic under a gutter. --no-thinking hides it.

-e sets output_config.effort. unset means the api default; xhigh for long agentic work, low for cheap turns.

fable costs ~2x the opus tier ($10/$50 vs $5/$25 per mtok) and needs 30-day data retention — not available under zdr. -m claude-opus-5 switches back. thinking/effort params are only sent to models that accept them, so older ids like claude-haiku-4-5 still work.

refusal fallbacks are on by default for fable/opus-5: if a classifier declines, the api retries on a fallback model in the same call and the status line notes the switch. --no-fallback disables.

context files

walks from cwd up to / collecting AGENTS.md and CLAUDE.md, and puts them at the head of the system prompt with a cache_control breakpoint. nearest file wins. loaded paths print on the first line.

transcripts

./.lambda/chats/<timestamp>.jsonl, one json object per line, written as the chat happens:

{"t":"session","v":1,"time":"…","model":"claude-fable-5","cwd":"…"}
{"t":"msg","m":{"role":"user","content":"…"}}
{"t":"drop","n":1}
{"t":"meta","k":"model","v":"claude-opus-5"}

msg holds the api message verbatim — text, thinking blocks with signatures, tool_use, tool_result — so a resume is byte-identical to what the model saw. a failed or interrupted turn rolls back and writes drop, so resume never replays a turn that didn't happen.

writes are queued in memory and flushed only when idle, so they never block the ui or a request. survives kill -9.

bash tool

runs /bin/sh -c in its own process group from the cwd. stdout+stderr merged, truncated at 64 kb, non-zero exit reported to the model. no state persists between calls. ctrl-c signals the whole group.

no approval prompt — if claude decides to run something, it runs. don't point it at a directory you'd mind it changing. --no-tools disables.

plugins

one .c file in plugins/. the makefile globs the dir and each file registers itself at startup — no registry, no codegen.

#include "plugin.h"

static int run(const char *args_json, buf *out)
{
    buf_appends(out, "pong");
    return 0;
}

static const lambda_tool ping = {
    .name = "ping",
    .description = "reply with pong",
    .schema = "{\"type\":\"object\",\"properties\":{}}",
    .run = run,
};

LAMBDA_TOOL_REGISTER(ping)

run gets the raw input json (parse with jsonx.h) and writes the result into out. optional: available() hides the tool when e.g. a key is missing, label() sets the transcript line. plugin_https_post() reuses lambda's tls.

exa

plugins/exa.c adds exa_search. set EXA_API_KEY to enable; without it the tool isn't offered. returns highlights by default (cheap in tokens); full_text for whole pages. num_results 1–25, plus type and category.

no malloc

every buffer is a static arena sized in src/config.h. buffers report truncation instead of growing; on overflow the turn rolls back and says so. jsmn, picohttpparser and bearssl are allocation-free too.

measured, not assumed: under an LD_PRELOAD interposer, a full session attributes zero allocations to lambda. the ~500 the process makes are glibc's, from getaddrinfo and stdio. objdump -R shows no allocator import.

bss is ~22 mb of arenas but demand-paged, so startup stays ~1 ms.

tls

bearssl has no os trust store, so root cas load from the first of: $SSL_CERT_FILE, /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, /etc/ssl/ca-bundle.pem, /etc/ssl/cert.pem. parsed lazily on first request.

layout

src/
  main.c    args, repl, slash commands
  api.c     messages api, sse parsing, tool loop
  http.c    https client on bearssl + picohttpparser
  ta.c      pem bundle → bearssl trust anchors
  tools.c   bash tool + dispatch
  plugin.c  plugin registry, shared https helper
  project.c AGENTS.md / CLAUDE.md discovery
  session.c ndjson transcripts, resume
  ui.c      transcript, wrapping, layout, line editor
  term.c    raw mode, alt screen, diffed cells, key decoding
  md.c      inline markdown → style flags
  jsonx.c   jsmn helpers
  util.c    fixed-capacity buffers
  config.h  all arena sizes
plugins/exa.c
vendor/     bearssl 0.6, jsmn, picohttpparser (all MIT)

the cell grid tracks display width, not codepoints — cjk and emoji take two columns, so a wide glyph claims a continuation cell. without it the grid and terminal disagree and stale glyphs linger when scrolling.

no termbox/curses: lambda uses ~5% of either, and termbox2 keeps pointers into arrays it reallocs. raw mode + diffed grid + key decoding is ~450 lines.

tests

make test builds tests/ under asan and ubsan with a deliberately tiny transcript arena, so compaction and eviction are exercised in a short run rather than only after hours of use.

make uitest covers the two display bugs that have actually shipped. it replays term.c's own escape output through an independent terminal emulator and checks the result matches term.c's back buffer — drift there is what leaves stale glyphs, usually via wide characters. it then fires bursts of scroll events faster than the repaint coalescing window and checks the settled screen equals a forced full repaint, which catches a deferred frame being stranded. LAMBDA_SELFTEST_FILL=n seeds the transcript so this runs with no api key.

ci compiles with -Werror on gcc and clang across linux and macos, runs both suites, and checks the static link.

limits

  • width is per codepoint, so grapheme clusters (flag emoji, skin tones) sit a column off
  • fresh dns/tcp/tls per turn (Connection: close) — a few hundred ms before the first token; keep-alive would fix it
  • glibc static builds still dlopen nss for dns; use musl for a truly self-contained binary

Contributors

montyanderson

5 commits

Languages

C

95.2%

Python

2.6%

Makefile

2.2%