Files
solo-c/README.md
T
Luxferre 5852a9732b Fix the curl GitHub mirror, repair the troubleshooting table
The GitHub fallback for curl was a 404 written untested: curl tags its
releases with underscores in the version, 8.22.0 becoming curl-8_22_0,
while the asset name keeps the dots. Derive the tag from the version
rather than assuming the two match. Verified by making the primary host
unresolvable and letting the fallback carry the fetch; the tarball
passes xz -t and extracts to curl-8.22.0/.

The README troubleshooting section was a definition list. It is now a
three-column table, matching the reference sections elsewhere in the
file, with a row added for make static-deps failing to download, which
had no coverage despite the script fetching two tarballs.

Two of its entries were wrong:

- One documented "cannot find -lcares", which the Makefile can no longer
  produce. The recipe passes the explicit path static-deps/libcares.a
  and the prerequisite check fails first with its own message. Verified
  by moving the archive aside and reading what make actually says.

- The ares_* entry gave one diagnostic for two causes that read opposite
  ways. `nm -u static-deps/libcurl.a | grep -c ares_` returns zero when
  the archive was built without c-ares, and nonzero when c-ares is in
  there and the failure is link order. Split them apart.
2026-10-03 09:22:23 +03:00

20 KiB

Solo-C

A tiny coding agent in a single C file.

Solo-C is a port of the Solo 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

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.