422 lines
21 KiB
Markdown
422 lines
21 KiB
Markdown
# 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`.
|
|
|
|
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 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 \
|
|
|
|
## 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
|
|
|
|
| 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.
|