Skip to content

Instantly share code, notes, and snippets.

@fourlexboehm
Last active September 1, 2026 04:33
Show Gist options
  • Select an option

  • Save fourlexboehm/a60e4ef9306744483731cd176bd02e9f to your computer and use it in GitHub Desktop.

Select an option

Save fourlexboehm/a60e4ef9306744483731cd176bd02e9f to your computer and use it in GitHub Desktop.
Minimal CLI Agent in C
// This is an entire CLI agent, it can do everything Claude code can do, using raw mode to support pasting, backspace,
// escape to cancel agents turn, and multiline prompts with Shift + enter,
// but with 2MB of RAM, neglibible CPU usage and one small C file.
// It is a single C file for the openai responses api, with no dependency other than libcurl, and an
// OPENAI_API_KEY env var.
// compile with `cc -lcurl -o cgent this_file.c` then install where you please.
// AI USAGE - This code was written without AI agents, largely to maintain my c skills, as such it is likely to have
// memory safety bugs.
#include <ctype.h>
#include <curl/curl.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <termios.h>
#include <stdbool.h>
#define BUF_SIZE 65536
#define INPUT_SIZE (1024 * 1024 + 4096)
// A terminal read returns a keystroke or a paste burst, never a whole prompt.
// take_prompt loops until stdin is drained and accumulates into prompt_buffer,
// so this only bounds a single read(2).
#define READ_SIZE 4096
// Escaping can double every byte, so this bounds a prompt of PROMPT_SIZE / 2.
// It is never written past what is typed, so untouched pages stay unmapped.
#define PROMPT_SIZE (1024 * 1024)
#define CARRY_SIZE 65536
#define RESPONSE_ID_LEN 55
// Longest marker we search for; a chunk's final MARKER_MAX bytes are carried
// over, so a marker split across chunks is seen on the next pass.
#define MARKER_MAX 40
static const char MODEL[] = "gpt-5.6-luna";
// Interpolated raw, so no quotes or newlines. Sent every request: previous_response_id
// does not carry instructions over, and a prefix that only sometimes holds them misses.
static const char INSTRUCTIONS[] =
"You are an AI agent, you have access to one tool, bash, use it in this "
"directory to see what's here yourself to answer solve my problems.";
static const char TOOLS_JSON[] =
",\"tools\":[{\"type\":\"function\",\"name\":\"bash\","
"\"description\":\"Run a bash command, returns stdout and stderr.\","
"\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}},"
"\"required\":[\"command\"],\"additionalProperties\":false},\"strict\":true}]";
static int tool_call_count;
static int input_len;
static char input_buffer[INPUT_SIZE];
// The response body is parsed in place as it arrives and never accumulated. Only
// the tail that might hold a marker split across a chunk boundary, or a tool call
// that has not fully arrived, is carried over to the next chunk.
static char carry[CARRY_SIZE];
static int carry_len;
static bool in_text, text_esc, saw_text;
// The id arrives mid-stream, but chaining from a cancelled turn leaves a function_call
// with no output, which the next request rejects. Committed only on completion.
static char previous_response_id[RESPONSE_ID_LEN + 1], pending_response_id[RESPONSE_ID_LEN + 1];
struct termios orig_termios;
void wout(const char *str, int len) {
write(STDOUT_FILENO, str, len);
}
static void erase_previous(void) {
static const char erase_previous[] = "\x1b[1D \x1b[1D";
wout(erase_previous, sizeof(erase_previous) - 1);
}
static void safe_exit(void) {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}
static void enable_raw_mode(void) {
tcgetattr(STDIN_FILENO, &orig_termios);
struct termios raw = orig_termios;
raw.c_lflag &= ~(ECHO | ICANON);
raw.c_iflag &= ~ICRNL;
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
atexit(safe_exit);
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
/* Return the number of characters on the last (logical) line. The prompt
* buffer contains a JSON string body, so a newline is stored as "\\n" and a
* quote/backslash may occupy two bytes. */
static int get_line_index(const char *prompt_buffer, const char *prompt_end) {
int line_index = 0;
bool first_line = true;
while (prompt_buffer < prompt_end) {
if (*prompt_buffer == '\\' && prompt_buffer + 1 < prompt_end) {
if (prompt_buffer[1] == 'n') {
line_index = 0;
prompt_buffer += 2;
first_line = false;
continue;
}
if (prompt_buffer[1] == '"' || prompt_buffer[1] == '\\') {
line_index++;
prompt_buffer += 2;
continue;
}
}
line_index++;
prompt_buffer++;
}
return line_index + first_line;
}
static void take_prompt(char *read_buffer, char *prompt_buffer) {
int line_index = 0;
char *prompt_pos = prompt_buffer;
*prompt_pos = 0;
for (int bytes_read;
(bytes_read = read(STDIN_FILENO, read_buffer, READ_SIZE - 1)) >= 0;) {
// read_buffer is compared as a C string below, and is not zeroed between
// reads, so terminate what actually arrived.
read_buffer[bytes_read] = 0;
for (int i = 0; i < bytes_read; i++) {
char c = read_buffer[i];
// No token appends more than two bytes plus the terminator. Once
// full, drop input rather than run off the end; editing keys still
// work, so the prompt can be cut back down.
bool full = prompt_pos - prompt_buffer + 3 > PROMPT_SIZE;
if (!iscntrl(c) && read_buffer[0] != '\x1b' ) {
if (full) continue;
wout(&c, 1);
switch (c) {
// The buffer is interpolated straight into the request as a
// JSON string, so escape here as newlines already are.
case '"':
case '\\':
*prompt_pos++ = '\\';
}
*prompt_pos++ = c;
line_index++;
} else {
switch (c) {
// backspace
case 127:
/* A newline is two bytes in the JSON buffer, but one
* terminal line break. Keep the buffer and cursor in
* sync when deleting it. */
if (prompt_pos == prompt_buffer)
break;
if (line_index == 0) {
/* At column zero the previous token is an escaped
* newline. The empty-buffer check prevents moving
* above the first line. */
if (prompt_pos - prompt_buffer < 2 ||
prompt_pos[-2] != '\\' || prompt_pos[-1] != 'n')
break;
prompt_pos -= 2;
*prompt_pos = 0;
line_index = get_line_index(prompt_buffer, prompt_pos);
/* Cursor is at the start of the current line. */
printf("\x1b[1F");
if (line_index > 0)
printf("\x1b[%dC", line_index);
fflush(stdout);
break;
}
/* Quotes and backslashes have an extra JSON byte. */
if (prompt_pos - prompt_buffer >= 2 &&
prompt_pos[-2] == '\\' &&
(prompt_pos[-1] == '"' || prompt_pos[-1] == '\\'))
prompt_pos -= 2;
else
prompt_pos--;
*prompt_pos = 0;
erase_previous();
line_index--;
break;
case '\r':
//buffer
line_index = 0;
wout("\n", 1);
if (bytes_read == i + 1) {
*prompt_pos = 0;
return;
}
if (full) break;
*prompt_pos++ = '\\';
*prompt_pos++ = 'n';
break;
case '\n':
//buffer
line_index = 0;
wout("\n", 1);
if (full) break;
*prompt_pos++ = '\\';
*prompt_pos++ = 'n';
break;
default:
// Handle escape sequences, some terminals send this for shift + enter
// Shift + Enter
if (!strcmp(read_buffer, "\x1b[27;2;13~")) {
if (!full) {
*prompt_pos++ = '\\';
*prompt_pos++ = 'n';
}
wout("\n", 1);
line_index = 0;
goto buffer_parsed;
}
printf("%d\n", c);
break;
}
}
}
buffer_parsed:;
}
// read(2) failed; the caller still needs a terminated buffer.
*prompt_pos = 0;
}
static void wout_codepoint(unsigned int cp) {
char b[4];
int n = 0;
if (cp < 0x80) b[n++] = cp;
else if (cp < 0x800) {
b[n++] = 0xC0 | cp >> 6;
b[n++] = 0x80 | (cp & 0x3F);
} else if (cp < 0x10000) {
b[n++] = 0xE0 | cp >> 12;
b[n++] = 0x80 | (cp >> 6 & 0x3F);
b[n++] = 0x80 | (cp & 0x3F);
} else {
b[n++] = 0xF0 | cp >> 18;
b[n++] = 0x80 | (cp >> 12 & 0x3F);
b[n++] = 0x80 | (cp >> 6 & 0x3F);
b[n++] = 0x80 | (cp & 0x3F);
}
wout(b, n);
}
static enum { JSON_TEXT, JSON_ESC, JSON_HEX } unescape_state;
static unsigned int unescape_cp, unescape_high;
static int unescape_hexlen;
static void reset_json_unescape(void) {
unescape_state = JSON_TEXT;
unescape_cp = unescape_high = 0;
unescape_hexlen = 0;
}
// Unescapes a JSON string body straight to stdout. Holds only the few bytes of
// escape state needed to resume, so it can be fed arbitrary chunk boundaries.
static void print_json_unescaped(const char *src, int n) {
for (int i = 0; i < n; i++) {
char c = src[i];
switch (unescape_state) {
case JSON_TEXT:
if (c == '\\') unescape_state = JSON_ESC;
else wout(&c, 1);
break;
case JSON_ESC:
unescape_state = JSON_TEXT;
switch (c) {
case 'n': wout("\n", 1); break;
case 't': wout("\t", 1); break;
case 'r': wout("\r", 1); break;
case 'b': wout("\b", 1); break;
case 'f': wout("\f", 1); break;
case 'u':
unescape_cp = 0;
unescape_hexlen = 0;
unescape_state = JSON_HEX;
break;
default: wout(&c, 1);
}
break;
case JSON_HEX:
unescape_cp = unescape_cp * 16 +
(c <= '9' ? c - '0' : (c | 32) - 'a' + 10);
if (++unescape_hexlen < 4) break;
unescape_state = JSON_TEXT;
if (unescape_cp >= 0xD800 && unescape_cp < 0xDC00) {
unescape_high = unescape_cp;
break;
}
if (unescape_high && unescape_cp >= 0xDC00 && unescape_cp < 0xE000)
unescape_cp = 0x10000 + ((unescape_high - 0xD800) << 10) +
(unescape_cp - 0xDC00);
unescape_high = 0;
wout_codepoint(unescape_cp);
}
}
}
// Appends src to dst as an escaped JSON string body, returns the new length.
static int json_escape(char *dst, int len, const char *src) {
for (; *src && len + 2 < INPUT_SIZE; src++) {
switch (*src) {
case '"': case '\\': dst[len++] = '\\'; dst[len++] = *src; break;
case '\n': dst[len++] = '\\'; dst[len++] = 'n'; break;
case '\t': dst[len++] = '\\'; dst[len++] = 't'; break;
default: if ((unsigned char)*src >= 0x20) dst[len++] = *src;
}
}
return len;
}
static void feed(char *chunk, int len, bool final);
static void run_agent(char *prompt, CURL *curl, CURL *multi) {
static struct curl_waitfd input = {
.fd = STDIN_FILENO,
.events = CURL_WAIT_POLLIN,
.revents = 0
};
struct termios raw_termios;
tcgetattr(STDIN_FILENO, &raw_termios);
char animation[] = "\x1b[1D!";
char c = 0;
raw_termios.c_cc[VMIN] = 0;
raw_termios.c_cc[VTIME] = 1;
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_termios);
snprintf(input_buffer, INPUT_SIZE, "\"%s\"", prompt);
do {
curl_multi_add_handle(multi, curl);
char *previous_json = NULL;
if (*previous_response_id)
asprintf(&previous_json, ", \"previous_response_id\":\"%s\"", previous_response_id);
// gpt-5.6 needs prompt_cache_key for reliable matching: without it an identical
// prefix routes by hash alone and lands elsewhere each turn. Every turn of a
// session shares the prefix, so one key per process; a reused pid costs a miss.
char *json;
asprintf(
&json,
"{\"model\":\"%s\",\"instructions\":\"%s\",\"prompt_cache_key\":\"cgent:%d\""
",\"input\":%s%s%s}",
MODEL, INSTRUCTIONS, (int)getpid(),
input_buffer,
TOOLS_JSON,
previous_json ? previous_json : ""
);
//printf("%s\n", json);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, strlen(json));
tool_call_count = carry_len = *pending_response_id = 0;
in_text = text_esc = saw_text = false;
int still_running;
do {
CURLMcode mc = curl_multi_perform(multi, &still_running);
if (!mc && still_running && c != 27) {
// extra_nfds must be 1, otherwise stdin is never polled, revents
// is never set, and ESC cannot cancel a stalled request.
mc = curl_multi_poll(multi, &input, 1, 80, NULL);
}
if (input.revents & CURL_WAIT_POLLIN) {
read(STDIN_FILENO, &c, 1);
input.revents = 0;
}
wout(animation, sizeof(animation) - 1);
if (animation[4] == '/') animation[4] = '!';
animation[4]++;
} while (still_running && c !=27);
erase_previous();
// Drain the multi queue before removing the handle, otherwise a transport
// failure is silently indistinguishable from an empty reply.
CURLMsg *msg;
for (int queued; (msg = curl_multi_info_read(multi, &queued));)
if (msg->msg == CURLMSG_DONE && msg->data.result != CURLE_OK)
printf("\x1b[31mcurl: %s\x1b[0m\n",
curl_easy_strerror(msg->data.result));
if (c != 27) {
long code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
if (code && code != 200) {
printf("\x1b[31mHTTP %ld\x1b[0m\n%s\n", code, json);
exit(1);
}
feed(carry, 0, true);
// Per iteration, not per turn: the tool loop attaches its output to this id.
if (*pending_response_id) strcpy(previous_response_id, pending_response_id);
if (!saw_text && !tool_call_count)
printf("\x1b[31m[no text or tool call in response]\x1b[0m\n");
fflush(stdout);
}
curl_multi_remove_handle(multi, curl);
free(json);
free(previous_json);
} while (tool_call_count && c != 27);
raw_termios.c_cc[VMIN] = 1;
raw_termios.c_cc[VTIME] = 0;
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_termios);
}
static void run_repl(CURL *curl, CURL *multi) {
char *read_buffer = malloc(READ_SIZE);
char *prompt_buffer = malloc(PROMPT_SIZE);
for(;;) {
wout(">", 1);
take_prompt(read_buffer, prompt_buffer);
run_agent(prompt_buffer, curl, multi);
}
free(read_buffer);
free(prompt_buffer);
}
static void run_tool(char *id, char *id_end, char *src, char *end) {
// `exec` with only redirections applies them to the whole shell, so this holds
// however the command is structured. Detaching stdin is what matters: popen
// inherits our raw-mode terminal, so anything that reads stdin (or prompts)
// blocks the agent forever. 2>&1 also makes this match the tool description.
static const char prefix[] = "exec </dev/null 2>&1\n";
static const int prefix_len = sizeof prefix - 1;
// The prefix is reserved at the head of the buffer and the command parsed in
// directly after it, so the shell gets one buffer with no second copy.
static char shell_command[BUF_SIZE], output[BUF_SIZE];
memcpy(shell_command, prefix, prefix_len);
char *command = shell_command + prefix_len;
int n = 0;
while (src + 2 < end && !(src[0] == '\\' && src[1] == '"' && src[2] == '}') &&
prefix_len + n + 1 < BUF_SIZE) {
char c = *src++;
if (c == '\\' && src < end && *src == '\\') {
src++;
c = *src++;
if (c == '\\' && src < end) c = *src++;
else if (c == 'n') c = '\n';
else if (c == 't') c = '\t';
else if (c == 'r') c = '\r';
}
command[n++] = c;
}
command[n] = 0;
erase_previous();
printf("\x1b[90m$ %s\x1b[0m\n", command);
fflush(stdout);
FILE *pipe = popen(shell_command, "r");
int out_len = 0;
bool truncated = false;
if (pipe) {
for (;;) {
int space = (int)sizeof(output) - 1 - out_len;
if (space > 0) {
int r = fread(output + out_len, 1, space, pipe);
if (r <= 0) break;
out_len += r;
} else {
// Keep draining so the child is not killed mid-write by SIGPIPE.
char sink[4096];
if (fread(sink, 1, sizeof sink, pipe) <= 0) break;
truncated = true;
}
}
pclose(pipe);
}
output[out_len] = 0;
wout(output, out_len);
if (truncated) wout("\n[output truncated]\n", 20);
if (!tool_call_count) {
input_buffer[0] = '[';
input_len = 1;
}
input_len += snprintf(input_buffer + input_len, INPUT_SIZE - input_len,
"%s{\"type\":\"function_call_output\",\"call_id\":\"%.*s\",\"output\":\"",
tool_call_count++ ? "," : "", (int)(id_end - id), id);
input_len = json_escape(input_buffer, input_len, output);
if (truncated)
input_len = json_escape(input_buffer, input_len, "\n[output truncated]");
input_len += snprintf(input_buffer + input_len, INPUT_SIZE - input_len, "\"}");
snprintf(input_buffer + input_len, INPUT_SIZE - input_len, "]");
}
// Finds the unescaped closing quote of a JSON string body. text_esc carries the
// "previous byte was a backslash" state across calls so a split escape sequence
// is not mistaken for the terminator.
static char *find_string_end(char *src, int n) {
for (int i = 0; i < n; i++) {
if (text_esc) { text_esc = false; continue; }
if (src[i] == '\\') { text_esc = true; continue; }
if (src[i] == '"') return src + i;
}
return NULL;
}
// Parses src[0..n) in place and returns the offset of the first byte that could
// not be consumed yet; the caller carries [ret, n) into the next chunk. With
// final set, everything is consumed.
static int parse_chunk(char *src, int n, bool final) {
int cur = 0;
for (;;) {
char *base = src + cur;
char *end = src + n;
int avail = n - cur;
if (avail <= 0) return n;
if (in_text) {
// find_string_end carries its escape state, so text streams out with
// no lookahead at all.
char *stop = find_string_end(base, avail);
print_json_unescaped(base, (stop ? stop : end) - base);
if (!stop) return n;
wout("\n", 1);
in_text = false;
cur = (stop - src) + 1;
continue;
}
char *call = strnstr(base, "\"type\": \"function_call\"", avail);
char *rid = strnstr(base, "\"id\": \"resp_", avail);
char *text = strnstr(base, "\"text\": \"", avail);
char *first = NULL;
int kind = 0;
if (call) { first = call; kind = 1; }
if (rid && (!first || rid < first)) { first = rid; kind = 2; }
if (text && (!first || text < first)) { first = text; kind = 3; }
// No marker here, so one can only have been split across the boundary.
// Everything before the last MARKER_MAX bytes is provably marker-free.
if (!first) {
if (final) return n;
return avail > MARKER_MAX ? n - MARKER_MAX : cur;
}
if (kind == 1) {
// Bound the field search to this output item so a truncated item
// cannot borrow the next one's call_id.
char *item_end = strnstr(first + 1, "\"type\": \"function_call\"",
end - (first + 1));
if (!item_end) item_end = end;
char *id = strnstr(first, "\"call_id\": \"", item_end - first);
char *command = strnstr(first, "\"arguments\": \"{\\\"command\\\":\\\"",
item_end - first);
char *id_end = NULL, *command_end = NULL;
if (id && command) {
id += sizeof("\"call_id\": \"") - 1;
command += sizeof("\"arguments\": \"{\\\"command\\\":\\\"") - 1;
id_end = memchr(id, '"', item_end - id);
command_end = strnstr(command, "\\\"}", item_end - command);
}
// Incomplete item: carry it whole and retry once the rest lands.
if (!id_end || !command_end)
return final ? n : first - src;
run_tool(id, id_end, command, command_end + 3);
cur = (command_end - src) + 3;
continue;
}
if (kind == 2) {
char *id = first + sizeof("\"id\": \"") - 1;
// Wait until the whole id has arrived, otherwise the copy picks up
// bytes that are not part of it yet.
if (end - id < RESPONSE_ID_LEN)
return final ? n : first - src;
strncpy(pending_response_id, id, RESPONSE_ID_LEN);
cur = (id - src) + RESPONSE_ID_LEN;
continue;
}
// kind == 3: start of an output_text value.
cur = (first - src) + sizeof("\"text\": \"") - 1;
in_text = true;
saw_text = true;
text_esc = false;
reset_json_unescape();
erase_previous();
}
}
// Parses a chunk together with whatever the last one carried over. Only the
// carry is ever copied; the body itself is parsed straight out of curl's buffer.
static void feed(char *chunk, int len, bool final) {
char *src = chunk;
int n = len;
if (carry_len) {
if (carry_len + len > CARRY_SIZE) {
printf("\x1b[31m[tool call exceeds %d bytes]\x1b[0m\n", CARRY_SIZE);
exit(1);
}
memcpy(carry + carry_len, chunk, len);
src = carry;
n = carry_len + len;
}
int keep = n - parse_chunk(src, n, final);
if (keep) memmove(carry, src + n - keep, keep);
carry_len = keep;
}
static size_t write_mem_callback(void *contents, size_t size, size_t nmemb, void *userp) {
(void)userp;
int len = size * nmemb;
feed(contents, len, false);
return len;
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
CURL *multi = curl_multi_init();
CURL *curl = curl_easy_init();
if (!curl) {
curl_easy_cleanup(curl);
exit(-1);
}
curl_easy_setopt(curl, CURLOPT_URL, "https://api.openai.com/v1/responses");
struct curl_slist *list = NULL;
char *api_key = getenv("OPENAI_API_KEY");
if (api_key == NULL) return -1;
char auth_header[] = "Authorization: Bearer \0 ";
strcat(auth_header, api_key);
list = curl_slist_append(
list,
auth_header
);
list = curl_slist_append(list, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem_callback);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
// No overall timeout: a long generation is legitimate. Give up only once the
// connection has stopped delivering anything at all.
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 120L);
enable_raw_mode();
run_repl(curl, multi);
curl_easy_cleanup(curl);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment