Solo-C: a tiny coding agent in a single C file
main.c is the whole agent: a readline REPL that talks to any OpenAI-compatible /chat/completions endpoint and exposes exactly one tool, bash(command). Commands run through the user's shell with stdout and stderr merged, a hard timeout, and a process-group kill; the output and an exit marker go back to the model until it answers without calling a tool. make static-deps builds curl and c-ares from source into static-deps/, so `make static` can link a fully static binary on distros that get in the way: the distro libcurl.a drags in libpsl, brotli, zstd and nghttp2, whose static objects are GCC 14 LTO bytecode a newer linker cannot read, and a minimal system often ships no libcares.a at all. Two details worth keeping in mind when editing mkstaticdeps.sh. curl 8.22 takes --enable-ares=PREFIX rather than the older --with-cares, and it only warns about options it does not recognise, so the wrong spelling turns off async DNS in silence instead of failing. And link order is load-bearing: libcares.a has to come after libcurl.a or ares_* stays undefined.
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
# 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)
|
||||
- GNU 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`.
|
||||
|
||||
Other targets:
|
||||
|
||||
make clean # remove solo and solo-static
|
||||
make install # install to $(PREFIX)/bin, default /usr/local/bin
|
||||
make uninstall # remove the installed binaries
|
||||
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.
|
||||
|
||||
## 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 \
|
||||
|
||||
## 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` |
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
> /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.
|
||||
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`). 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
|
||||
|
||||
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:
|
||||
|
||||
| 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 thrown away. |
|
||||
| Sitting at the prompt | Solo survives and stays usable, but the flag lingers, so the next line you type is silently discarded. |
|
||||
|
||||
That last case is the one wart: press Ctrl-C once at an idle prompt and your
|
||||
next command vanishes without a word. Press it twice and the second one goes
|
||||
through, so it is self-correcting in practice.
|
||||
|
||||
## 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
|
||||
|
||||
`error while loading shared libraries`
|
||||
The dynamic binary needs libcurl, json-c and readline at runtime. Install
|
||||
them, or build `solo-static`.
|
||||
|
||||
`solo: command not found` after `make install`
|
||||
`/usr/local/bin` is not on your `PATH`, or you installed to
|
||||
`$HOME/.local/bin`.
|
||||
|
||||
`pkg-config --cflags libcurl json-c readline` prints nothing
|
||||
The development headers are missing. Install `libcurl4-openssl-dev`,
|
||||
`libjson-c-dev` and `libreadline-dev` (names vary by distro).
|
||||
|
||||
`make static` fails with "bytecode stream ... generated with LTO version"
|
||||
Known GCC 14 vs 15 mismatch in the distro libcurl.a. Run `make static-deps`
|
||||
to build a clean libcurl, then `make static` again.
|
||||
|
||||
`make static` fails with "cannot find -lcares", or cannot find -lssl, -lz, ...
|
||||
Your distro has no static archives for some of these. `make static-deps`
|
||||
only builds curl and c-ares; ssl, crypto, z, idn2, unistring, readline,
|
||||
ncursesw and json-c must come from the distro as `.a` files.
|
||||
|
||||
`make static` fails with undefined references to `ares_*`
|
||||
Either the archives are in the wrong order on the link line (libcares.a must
|
||||
come after libcurl.a), or libcurl.a was built without c-ares in the first
|
||||
place. Check with `nm -u static-deps/libcurl.a | grep ares_`, which should
|
||||
list them, and rebuild with `make static-deps-force`.
|
||||
|
||||
`Illegal instruction` from a binary built here, run elsewhere
|
||||
`-march=native`. Rebuild without it:
|
||||
`make CFLAGS="-Wall -Wextra -O2 -std=c99"`.
|
||||
|
||||
The model keeps asking for a command that times out
|
||||
The limit is `SOLO_TIMEOUT`, 120 s. Raise it with `SOLO_TIMEOUT=600`, or
|
||||
remind the model to background long jobs and poll.
|
||||
|
||||
`request failed` with no output
|
||||
The endpoint rejected the call. Check `/api_key` and `/endpoint`, then
|
||||
`/models` to confirm the endpoint answers and which models it lists.
|
||||
Anything the server returns is printed as-is, so the HTTP error body is
|
||||
usually the explanation.
|
||||
|
||||
The transcript looks stale
|
||||
Session state lives in `.solo_sess.json` in the current directory. Run
|
||||
`solo` where you want that conversation, or delete the file to start over.
|
||||
|
||||
## Credits
|
||||
|
||||
Created by Luxferre in 2026, released into the public domain with no warranties.
|
||||
Reference in New Issue
Block a user