# Solo-C A tiny coding agent in a single C file. Solo-C is a port of the [Solo](https://solo.luxferre.top) coding agent: a read-eval-print loop that talks to any OpenAI-compatible chat-completions endpoint and gives the model exactly one tool, `bash`. The model writes a shell command, Solo-C runs it through your shell, feeds the combined output and exit code back, and repeats until the model answers without asking for a tool. The whole client is one ~400-line file, `main.c`, with no third-party code beyond libcurl, json-c and readline. ## Requirements Build-time: - A C99 compiler (GCC or Clang; built and tested with GCC 14) - Make - `pkg-config` - libcurl, json-c and GNU readline development headers Runtime (dynamic build): the matching shared libraries, plus a shell. Install the dependencies with your package manager, for example: ``` # Debian / Ubuntu sudo apt install build-essential pkg-config libcurl4-openssl-dev libjson-c-dev libreadline-dev # Alpine sudo apk add build-base pkgconf curl-dev json-c-dev readline-dev # Void Linux sudo xbps-install -S gcc make pkg-config curl-devel json-c-devel readline-devel ``` `make` picks up the include and library flags through `pkg-config`, so there is nothing to configure by hand. ## Build ``` make ``` That produces a stripped dynamic binary named `solo` in the project directory. The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`. Both binaries are linked under a temporary name and then renamed, so `make` works while an agent is still running from the old one, and a link failure leaves the previous binary in place. Other targets: make clean # remove solo and solo-static make install # install to $(PREFIX)/bin, default /usr/local/bin make installdir # print the directory install would use make uninstall # remove the installed binaries make test # offline regression tests, no API key or network make static-deps # build the static curl and c-ares archives, see below ## Static build `make static` links a fully static `solo-static`, useful for running the agent on a machine with no shared libraries, or from a rescue shell. It does not work out of the box everywhere, for two reasons. On Alpine and on any distro whose `libcurl.a` was built by a different GCC than yours, the distro archive pulls in libpsl, brotli, zstd and nghttp2, whose static objects ship LTO bytecode from GCC 14. A GCC 15 linker refuses to read it ("bytecode stream ... generated with LTO version 14.0 instead of the expected 15.1"), so `psl_*`, `BrotliDecoder*` and `ZSTD_*` never resolve. `-fno-lto` does not help, because the objects are bytecode, not C. And a minimal system often has no `libcares.a` at all. Void Linux is a live example: c-ares ships there as a shared library only, so `-lcares` in the link line has nothing to resolve against and `make static` fails outright. The fix is to build both archives from source. Build them once with: make static-deps That fetches and compiles c-ares and curl into `static-deps/`: static-deps/libcares.a c-ares, built static, from source static-deps/libcurl.a curl, built static, without psl/brotli/zstd/nghttp2 static-deps/cares-prefix/ headers and libcares.pc that curl's configure reads It needs network access, a compiler, and a few minutes. Then: make static c-ares is built here rather than taken from the system for the reason above: no archive may exist, and where one does it can carry the same LTO bytecode as the distro libcurl.a. Building it also pins the version, so two machines do not quietly disagree about which resolver is inside the binary. The two archives go on the link line in that order, because `libcurl.a` references `ares_*` symbols and the linker only pulls archive members that satisfy an undefined symbol seen earlier. Reverse them and you get undefined references to `ares_library_init`, `ares_version` and friends. The result has no HTTP/2, no brotli or zstd `Content-Encoding`, and no public-suffix cookie checks. None of that matters to a JSON API client. Versions and reuse: | Variable | Default | What it pins | | --------------- | ------- | ---------------------------------------- | | `CURL_VERSION` | 8.22.0 | curl release fetched from curl.se | | `CARES_VERSION` | 1.34.8 | c-ares release, fetched from c-ares.dev | | `FORCE` | unset | set to `1` to rebuild even if fresh | `make static-deps` reuses whatever is already built, so a rerun is instant. Reuse is keyed on a stamp file beside each archive rather than on the file merely existing, so bumping a version rebuilds exactly that one archive, and a stale `libcurl.a` from an unrelated build can never be mistaken for a current one. To rebuild both regardless: make static-deps-force One trap worth knowing: curl 8.22 spells the option `--enable-ares[=PREFIX]`, not the older `--with-cares`, and curl only *warns* about options it does not recognise. A mistyped flag therefore fails silently, disabling async DNS and leaving you to find out at link time from a pile of undefined `ares_*` symbols. The same applies to `--without-librtmp`, which curl 8.22 no longer knows. Neither spelling appears in the script. Override `CURL_STATIC` or `CARES_STATIC` if you keep prebuilt archives elsewhere. Note that `make static` still wants static versions of ssl, crypto, z, idn2, unistring, readline, ncursesw and json-c from your distro; only the two problem archives are built from source here. ## Install sudo make install # /usr/local/bin/solo sudo make install PREFIX=$HOME/.local # ~/.local/bin/solo make install DESTDIR=/tmp/pkg PREFIX=/usr Remember to put `~/.local/bin` on your `PATH` if you install there. `install` takes any location you name, so nothing has to be patched to install somewhere unusual: make install BINDIR=/opt/solo/bin # an explicit directory make install DIR=/opt/solo/bin # the same, spelled in one macro make install DIR=~/.local/bin # a leading ~ is expanded by the shell make install DIR="$HOME/bin" # spaces in the path are fine make install INSTALL="install -C" # a different install program make installdir DIR=/opt/solo/bin # prints /opt/solo/bin, installs nothing `DIR` wins over `BINDIR`, and `DESTDIR` is prepended to either, so `make install DESTDIR=/tmp/pkg DIR=/usr/bin` stages into `/tmp/pkg/usr/bin`. `make uninstall` resolves the location the same way, so pass the same macros to undo an install: `make uninstall DIR=~/.local/bin`. `solo-static` is installed alongside `solo` when it has been built (`make static`). To install only that one, use `make install-static`. ## Run ./solo or, once installed: solo Start in the directory you want the agent to work in. Solo-C stores its session next to you in `.solo_sess.json`, so different projects keep different conversations, and the agent's shell commands run with that directory as their working directory. Set the endpoint and key before the first request: export SOLO_API_KEY=your-key-here solo or from inside the session: > /api_key your-key-here > /endpoint https://api.kilo.ai/api/openrouter Then just type what you want: > list every *.c file here with its line count, then count the total Anything that does not start with `/` or `!` is sent to the model as a task. A trailing backslash continues a line, which is handy for long tasks: > build the project, then run the tests, and fix whatever fails \ Or hand it a prompt as an argument and it runs that one task and exits, with no editor and no prompt loop: see [Non-interactive mode](#non-interactive-mode) below. ## Non-interactive mode Hand Solo-C a prompt as an argument and it runs that single task and exits. The answer arrives on `stdout` together with progress and token stats, the ready banner on `stderr`, so a task drops straight into pipes, scripts and `cron`: ./solo "list every TODO in this repo and fix the trivial ones" | tee todo-report.md answer=$(solo "summarize the last commit") solo '!make test' All words are joined into one task, so quoting is optional, and an empty or whitespace-only argument opens the REPL instead. Solo-C exits `0` when the task is done, `1` when it failed or the endpoint was unreachable, `130` on `Ctrl-C`, and with the command's own status for a `!cmd` task. ## Subagents Solo-C splits large work by itself. The system prompt tells it to copy the current session file `.solo_sess.json` into a fresh directory under `$SOLO_TMPDIR` and launch another copy of itself from there, on the same endpoint and with the same model, tools and system prompt: mkdir -p $SOLO_TMPDIR/sub1 && cp .solo_sess.json $SOLO_TMPDIR/sub1/ cd $SOLO_TMPDIR/sub1 SOLO_ENDPOINT=https://api.kilo.ai/api/openrouter ~/solo 'audit the SQL layer, read-only' The subagent runs its own tool loop in its own session file and reports its final answer on `stdout`, while its ready banner goes to `stderr`, so independent subtasks can run in parallel without filling the caller's context with their tool output. The prompt names Solo-C's own absolute path, resolved through `/proc/self/exe`, so the binary is relaunched wherever it was started from. Put `SOLO_MODEL`, `SOLO_REASON`, `SOLO_TEMP` or `SOLO_SYSPROMPT` in front of `SOLO_ENDPOINT` to give a subagent a different setup. ## Configuration Everything is configurable at three levels, from weakest to strongest: 1. Built-in defaults (see `SOLO_*` below). 2. Environment variables, read once at startup. 3. Session commands, which change the live session and are saved to disk. | Setting | Default | How to change it | | ------------- | ------------------------------------ | ----------------------------------- | | endpoint | `https://api.kilo.ai/api/openrouter` | `SOLO_ENDPOINT`, `/endpoint` | | api key | unset | `SOLO_API_KEY`, `/api_key` | | model | `kilo-auto/free` | `SOLO_MODEL`, `/model` | | reasoning | `high` | `SOLO_REASON`, `/reasoning_effort` | | temperature | `0.7` | `SOLO_TEMP`, `/temperature` | | timeout | `120` seconds | `SOLO_TIMEOUT` (env only) | | system prompt | built-in | `SOLO_SYSPROMPT` | | scratch dir | `$TMPDIR/solo`, or `/tmp/solo` | `SOLO_TMPDIR` | | colors | on when stdout is a TTY | `NO_COLOR=1` | In non-interactive mode the banner is printed on `stderr`, but the color check still follows `stdout`, so a piped run stays free of escape codes. Session commands take precedence and are persisted, so a model you set by hand survives a restart even if the environment says something else. Two settings cannot be changed from inside the session, because they are read once at startup: the timeout (`SOLO_TIMEOUT`) and the system prompt (`SOLO_SYSPROMPT`). `/timeout 600` is accepted by the generic parameter handler below, but it only writes a `timeout` field into the saved session, which the API ignores and which does not change the command timeout. Restart with the environment set instead. The timeout is not persisted with the session: the tool description, which quotes it, is rebuilt from the current environment on every start, so a session saved under `SOLO_TIMEOUT=42` and reopened under `SOLO_TIMEOUT=99` describes and enforces 99 seconds. ## Slash commands | Command | Effect | | ------------------ | --------------------------------------------------------------- | | `/help` | Show the command list. | | `/quit` | Exit. | | `/clear` | Reset the conversation, keep the system message and parameters. | | `/models` | List model IDs offered by the endpoint. | | `/api_key [key]` | Show or set the key. `null`, `none`, `off` or `-` clears it. | | `/endpoint [url]` | Show or set the base URL. Trailing slashes are stripped. | | `/name [value]` | Get or set any model parameter. Bare `/name` prints it. | | `!cmd` | Run a shell command directly, without involving the model. | `/name` is a generic model-parameter editor. The value is typed from its spelling: `true` and `false` become booleans, a parseable number becomes a number, and anything else stays a string. A number written without a decimal point or an exponent stays an integer, so `/max_tokens 4096` is sent as `4096` and not as `4096.0`, which a strict endpoint rejects; `0.7` and `1e3` are sent as floats. > /model deepseek/deepseek-v4.1-flash > /reasoning_effort high > /temperature 0.2 > /max_tokens 4096 > /top_p 0.95 > /temperature 0.2 Because it is generic, any parameter your endpoint understands can be set without a change to the source. A parameter the session does not know about prints `unset`. `!cmd` is your escape hatch when the model is slow, wrong, or you simply do not want to pay for a request: > !git status --short > !make -j8 >/tmp/build.log 2>&1 && tail -5 /tmp/build.log Lines that begin with a space are treated as tasks but are kept out of the readline history, which is a cheap way to avoid persisting a key pasted by mistake. ## Environment variables | Variable | Meaning | Default | | ------------------ | ----------------------------------------------------------- | -------------------------------- | | `SOLO_API_KEY` | Bearer token sent as `Authorization: Bearer ...` | unset | | `SOLO_ENDPOINT` | Base URL of an OpenAI-compatible API | `https://api.kilo.ai/api/openrouter` | | `SOLO_MODEL` | Model ID | `kilo-auto/free` | | `SOLO_REASON` | `reasoning_effort` | `high` | | `SOLO_TEMP` | Sampling temperature | `0.7` | | `SOLO_TIMEOUT` | Seconds before a shell command is killed | `120` | | `SOLO_SYSPROMPT` | Replaces the built-in system prompt | built-in | | `SOLO_TMPDIR` | Scratch directory suggested to the model | `$TMPDIR/solo`, else `/tmp/solo` | | `NO_COLOR` | Any value disables ANSI color | unset | | `SHELL` | Shell used to run commands; `/bin/bash` or `/bin/sh` fallback | `/bin/sh` | `SOLO_SYSPROMPT` replaces the prompt outright, including the housekeeping rules about scratch space and file writing. The built-in prompt also tells the model to keep scratch files in the scratch directory, so setting it means also choosing where that directory is: export SOLO_TMPDIR=/var/tmp/solo export SOLO_SYSPROMPT='You are a terse shell assistant. Scratch in /var/tmp/solo.' ## Session file The live conversation is written to `.solo_sess.json` in the current working directory after every message and every tool result. It holds the model parameters, the tool definition, and the whole message list in pretty-printed JSON. Consequences worth knowing: - Re-running `solo` in the same directory resumes the conversation. - Deleting the file starts fresh, and `/clear` empties it without touching the system message. - The file contains the full transcript, so it can contain secrets you typed. It is written without restrictive permissions and is **not** a secret store; keep it out of version control (see `.gitignore`). - Two `solo` processes in the same directory will fight over it. Use one per directory, or a separate directory per session. ## How it works The loop, in order: 1. `readline` reads one line. A trailing `\` continues it on the next line. 2. `dispatch` routes the line: a slash command changes settings, `!` runs a shell command locally, anything else becomes a task. The line is right-trimmed first, so `/quit ` and `/help ` are still commands and a value such as `/temperature 0.5 ` carries no trailing spaces; a task keeps its exact text, leading space included. 3. A task is appended to the message list as a `user` message and saved. 4. `hc` in the forked child performs one HTTP request with libcurl, wrapping the transport-level result as `{"status", "success", "content"}` and writing it back through a pipe. The fork keeps a crash, hang or DNS failure in curl from taking the agent down with it. 5. The parent parses the envelope, parses the inner JSON, and reads `choices[0].message`. Reasoning text is printed between `--- reasoning start ---` and `--- reasoning end ---`, the content is printed as it arrives, and token usage is reported when the endpoint sends it. 6. If the message carries `tool_calls`, the assistant message is saved, each tool call runs in order through `run`, and each result is appended as a `tool` message ending with an exit marker. Then go back to step 4 with the same session, which now includes the results. 7. When the model replies with no tool calls, the loop ends and control returns to the prompt. Tool execution in detail: - Commands run through `$SHELL` (falling back to `/bin/bash`, then `/bin/sh`) with `-c`, in the current working directory. - stdout and stderr are merged into one pipe, so the model sees them together in the order they were produced. - The child gets its own process group (`setpgid`), claimed by the parent as well as the child, so the group signal cannot lose the race against the child starting up. On timeout or Ctrl-C the whole group is signalled, so `make`, a shell pipeline and everything they spawned all die, not just the direct child. - The output is returned verbatim, with an exit marker on the last line. Nothing is truncated or summarised, which is the point: the model sees exactly what the terminal saw. The request is a plain non-streaming `POST /chat/completions` with `stream: false` and `tool_choice: "auto"`, carrying exactly one tool: bash(command: string) -> combined stdout/stderr plus an exit marker `command` is required by the schema. A tool call that arrives without a usable one, which happens with a flaky router or a truncated stream, is answered with an error instead of being passed to the shell as an empty string, and three rounds in a row with nothing to run end the turn rather than loop on a peer that never sends a command. Reasoning fields are handled defensively, because providers disagree on the name: `reasoning` and `reasoning_content` are both read, and both are stored back into the transcript so that a follow-up turn can reuse them. ## Timeouts, exit codes and interrupts Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by default), and the exit status is reported to the model as a marker line. The deadline comes from a monotonic clock, so a command is not killed early by a wall clock adjustment. A `SOLO_TIMEOUT` below 1 means no limit, for the rare command that has to run to completion. | Status | Meaning | | ------- | ------------------------------------------------------------------ | | `0` | Success. | | `N` | The command exited with status N. | | `124` | Still running after the timeout, so it was killed. | | `130` | Interrupted with Ctrl-C, so it was killed. | | `127` | The shell itself could not be executed. | | `128+N` | The command was killed by signal N. | Because the marker is explicit, the model does not have to guess whether a timeout happened. The default prompt tells it what to do about one: start long work in the background with `nohup ... &` and poll the log, rather than blocking on a command that will be killed. Ctrl-C is never fatal, but it is handled slightly differently depending on where you are when you press it: | Where you are | What happens | | -------------------- | ------------------------------------------------------------------- | | Running a command | The process group is killed, exit 130 is reported, the turn ends. | | Waiting on the API | The request is abandoned and the turn ends. | | Part way through a line | The half-typed line is cleared; the next line runs normally. | | Mid way through a multi-line task | The whole task is abandoned. | | Sitting at the prompt | Solo survives and stays usable; the next line runs normally. | The half-typed line is cleared by Solo itself: readline only redraws the prompt on Ctrl-C and leaves the text in its buffer, so Solo registers a signal hook that empties the line, the way bash does. ## Example session $ cd ~/proj/solo-c $ SOLO_API_KEY=$KEY ./solo Agent Solo ready [https://api.kilo.ai/api/openrouter | kilo-auto/free | high] > !make solo --- $ make solo gcc -Wall -Wextra -O2 -std=c99 -march=native ... -o solo main.c -lcurl -ljson-c -lreadline strip solo > build it and tell me how big it is --- requesting... --- tool call_x: $ make solo gcc ... -o solo main.c strip solo [exit 0] I'll rebuild and check the size. --- $ ls -l solo -rwxr-xr-x 1 lux lux 35896 Oct 3 08:25 solo [exit 0] It builds cleanly and the stripped binary is about 35 KB. --- [kilo-auto/free] tokens: 412 (prompt 380, completion 32) > /temperature 0.3 temperature = 0.3 > /quit ## Troubleshooting | Symptom | What it means | Fix | | --- | --- | --- | | `error while loading shared libraries` | Dynamic build, runtime libraries absent. | Install libcurl, json-c and readline, or use `solo-static`. | | `solo: command not found` after `make install` | `$(PREFIX)/bin` is not on `PATH`. | Add it, or install with `PREFIX=$HOME/.local`. | | `pkg-config --cflags libcurl json-c readline` prints nothing | Development headers are missing. | Install `libcurl4-openssl-dev`, `libjson-c-dev`, `libreadline-dev`. | | `make static-deps` cannot download a tarball | No network, or every mirror for that file is down. | Retry. It tries c-ares.dev then GitHub for c-ares, curl.se then GitHub for curl. | | `make static` says `missing static-deps/libcares.a` | The prerequisite check caught it; the link never started. | `make static-deps`, or `make static-deps-force` if a stale copy is in the way. | | `make static` says `cannot find -lssl`, `-lz`, `-lidn2`, ... | The distro ships no static archive for a dependency. | Install the `-dev` packages that ship `.a` files. Only curl and c-ares are built here. | | `make static` says `bytecode stream ... generated with LTO version` | The distro `libcurl.a` holds GCC 14 LTO objects a newer linker cannot read. | `make static-deps-force`, then `make static`. | | `make static` reports undefined `ares_*` | Either link order, or a libcurl built without c-ares. | Run `nm -u static-deps/libcurl.a \| grep -c ares_`. Zero means the archive is stale or foreign: rebuild with `make static-deps-force`. Nonzero means c-ares is in there and the order is wrong: `CARES_STATIC` must follow `CURL_STATIC`. | | `Illegal instruction` on another machine | `-march=native` in the default `CFLAGS`. | `make CFLAGS="-Wall -Wextra -O2 -std=c99"`. | | The model keeps hitting the timeout | `SOLO_TIMEOUT`, 120 s by default. | Raise it with `SOLO_TIMEOUT=600`, or have the model background long jobs and poll. | | `request failed`, possibly with a JSON body | The endpoint refused the call; the body is printed as-is. | Check `/api_key` and `/endpoint`, then `/models`. | | The transcript looks stale | State lives in `.solo_sess.json` in the current directory. | Run `solo` where that conversation lives, or delete the file. | ## Credits Created by Luxferre in 2026, released into the public domain with no warranties.