Developer tools · ICPP User Manual · This page as Markdown (save it as CLAUDE.md in your project)
ICPP for AI agents
Rules for writing ICPP web sites with an AI coding agent (Claude Code, Codex, Cursor, or any model that reads a file of instructions). Copy this file into the project as CLAUDE.md, AGENTS.md or .cursorrules, or paste it at the top of the first prompt. Every rule here was measured against the shipped interpreter (build 1.0.0 of 2026-09-30), not guessed. The full reference is the ICPP User Manual beside this file; this page is what the manual does not say in the first ten minutes.
Written for people who build with the ICPP Web Suite (Windows), the Linux kcpp drop, the ICPP Runner on Android, or an Apache host. The nouhata.com marketplace (about 40 pages, 700 automated checks) is written exactly this way, and every pattern below comes from it.
1. What you are writing
An ICPP site is a folder:
public/ the pages; index.icpp.html is the front page; css, js, images beside them
lib/ ICPP code every page #includes (helpers); never served
db/ SQL files that create the database and its tables, run once, in name order
env.txt KEY=VALUE lines the pages read with icpp_getenv() (Runner and iwebserver)
A page is HTML with <?icpp ... ?> blocks. All the blocks of one page are folded into ONE main() and run by kcpp -icpp for every request, in a fresh process. Output written with iprint(...) lands where the block is. There is no session in memory between requests: everything persists in the database or in cookies.
<?icpp
#include "../lib/app.c"
int db; int n;
db = app_db();
n = 0;
?>
<!doctype html>
<html><head><meta charset="utf-8"><title>Things</title></head><body>
<h1>Things</h1>
<?icpp
$r = icpp_sql_query(db, "select thing_id, name from things order by thing_id;");
while (icpp_sql_next($r))
{
n = n + 1;
iprint("<p>", icpp_html_escape(icpp_trim(icpp_sql_get($r, "name"))), "</p>");
}
icpp_sql_free($r);
if (n == 0) iprint("<p>Nothing yet.</p>");
icpp_sql_disconnect(db);
?>
</body></html>
#include "../lib/app.c" inside the first block is hoisted above main(), so a library file holds function definitions and nothing else runs at include time. A page cannot define its own functions: helpers go in lib/.
2. The language rules that break generated code
These are the mistakes every model makes on its first ICPP page. Each was measured.
- Definition order is call order. A function must be defined above any call that passes it arguments. There are no prototypes and no headers. In
lib/, put the lowest-level helpers first. - Declare with C types, or use
$name.int db; char *s;are ordinary locals.$nameis an untyped local that needs no declaration and takes whatever it is assigned (number, string, handle).$namemay only appear inside a function; in a page every block is insidemain(), so it is fine there. In a standalone.icppscript, write amain()and put the code in it. - Handles are
int. A database connection, a query result, a list, a map and a set are allinthandles. A function that takes one declaresint, not a pointer. Free what you open:icpp_sql_free,icpp_list_free,icpp_map_free,icpp_sql_disconnect. - Never index or dereference a
char *.s[0],*s, pointer arithmetic ands == "x"are wrong (the last compares addresses). Every string operation is a builtin:icpp_strcmp,icpp_strlen,icpp_substr,icpp_strstr,icpp_trim,icpp_replace_all,icpp_split,icpp_re. - Build strings with
icpp_concat(...), any number of arguments, numbers welcome:icpp_concat("id=", id, "&n=", n). Do not join with+(measured:$t + "cd"did not join). Numbers to text:icpp_concat("", n). Text to number:icpp_atoi(s)(stops at the first non-digit, "" gives 0). iprintappends a newline after each call. Print whole tags in one call; never split a tag across twoiprints.iprinttakes any number of arguments and no format string;printfexists for C-style formats.- Regex escapes need a double backslash. Inside an ICPP string,
\.does not reach the regex engine; write\\.or use a class:[.],[?].icpp_re(text, "^[0-9]+$")returns the matched text or "". To test "is the whole string digits", compareicpp_strlen(icpp_re(s, "^[0-9]+$"))withicpp_strlen(s). - Escape everything from a visitor before printing it:
icpp_html_escape(s)for HTML, and see the SQL rule below. Everyiprintof a database value or a form field goes through it. continue,break,for,else if,?:,+=all work. Loop withwhileorfor. Recursion works.- A
$varkeeps its last type. Do not assign a number and later a string to the same$var; use two names.
3. Requests, forms, cookies, redirects
The page runs as a CGI program. The web server (iwebserver, the Runner, Apache with nouhata-cgi) sets the usual variables; read them with icpp_getenv: REQUEST_METHOD, QUERY_STRING, HTTP_COOKIE, HTTP_USER_AGENT, REMOTE_ADDR, REQUEST_URI, HTTPS.
- Form fields and query parameters both arrive through
icpp_getparam("name"), URL-decoded, "" when absent. A POST body inapplication/x-www-form-urlencodedis merged into the same place, so a field is read the same way whether the form was GET or POST. - Was it a POST?
icpp_strcmp(icpp_getenv("REQUEST_METHOD"), "POST") == 0. - Cap every field:
icpp_substr(icpp_trim(icpp_getparam("title")), 0, 120). - Headers (Set-Cookie, Status, Location, Content-Type) cannot be printed: the server has already sent its own. They are appended, one per line, to the file named in the environment variable
ICPP_HEADERS_FILE(NOUHATA_HEADERS_FILEon an Apache host set up like nouhata.com; set both to the same file when you write a server). The server emits that file before the body. Helper:
void app_header(char *line)
{
$path = icpp_getenv("ICPP_HEADERS_FILE");
if (icpp_strlen($path) == 0) return;
$cur = icpp_file_read($path);
icpp_file_write($path, icpp_concat($cur, line, "\r\n"));
}
void app_redirect(char *url)
{
app_header("Status: 303 See Other");
app_header(icpp_concat("Location: ", url));
iprint("<a href=\"", icpp_html_escape(url), "\">Continue</a>");
icpp_exit(0);
}
- After a POST that changed something, redirect (303) to a GET page. Do the work,
icpp_sql_disconnect(db), thenapp_redirect(...). Reloading never repeats the change. - Cookies: read
icpp_getenv("HTTP_COOKIE")and find your name in it (icpp_re(icpp_concat("; ", cookie), "; sid=[0-9a-f]+")); set one withSet-Cookie: sid=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000throughapp_header. A session is a row in asessionstable keyed by a random id fromicpp_random_hex(16). - CSRF: keep a 32-hex token in the session row, print it as a hidden
csrffield in every form, and refuse a POST whosecsrpfield differs. - File uploads (multipart) need the server's help; the Windows web suite and the Apache handler support them, the Runner does not yet.
4. The database (InternetSQL through icpp_sql_*)
int db; int r;
db = icpp_sql_connect(icpp_getenv("ICPP_DB_HOME"), "shop"); // home directory, database name
r = icpp_sql_query(db, "select thing_id, name from things where thing_id=5;");
if (icpp_sql_next(r)) iprint(icpp_trim(icpp_sql_get(r, "name")));
icpp_sql_free(r);
icpp_sql_disconnect(db);
Rules, all measured on InternetSQL 1.25:
- Every value in SQL is either an int or a quoted string with
'doubled. There are no prepared statements. One helper does all the quoting:char *q(char *v) { return icpp_concat("'", icpp_replace_all(v, "'", "''"), "'"); }and every string reaches SQL through it. Numbers areicpp_atoi'd first. - Name the columns in every INSERT:
insert into things (thing_id, name, made_utc) values(...). A positional INSERT breaks silently the day a column is added. - ORDER BY only on columns in the SELECT list. Ordering by a column you did not select returns no rows and no error.
- One JOIN per SELECT. Denormalise instead: keep a copy of the name you display (
shop_nameon the product row) rather than joining twice. - No
count(*). Count rows in a loop, or keep counters. - Trim what you read:
charcolumns come back padded in some cases, soicpp_trim(icpp_sql_get(r, "name"))everywhere. - Ids are yours to allocate. No autoincrement: keep a
seq_counterstable (name, next_value), read, add one, write back, and take a lock around it (a lock is a file you create withicpp_file_writeand remove; two requests are two processes). - Timestamps are text
"YYYY-MM-DDTHH:MM:SSZ"inchar(24); they sort and compare as strings. Money is whole cents innumber(19); never floats. - Column limits:
char(n)up to 240 on a wide table (the engine refuses more with "Can't add field"); usetext(8000)for long text. - A failed statement does not return 0. Check
icpp_sql_status(db)when a write may fail, and test writes with a following SELECT in your checks. - The home must exist with a
dbfolder inside before the first connect;create database shop;then creates the database through a connection to"default". Migrations are filesdb/001.sql,db/002.sql, ... each ending with a row in aschema_versionstable, so a deployment applies only the new ones. - Comments only between statements, alone on their line (
-- ...); a comment after a column or after;breaks the next statement. - Every page opens its own connection and closes it. A query on a closed connection returns nothing, silently, so compute redirect targets before
icpp_sql_disconnect.
5. Page and library skeleton
lib/app.c, in this order (lowest first):
char *q(char *v) { return icpp_concat("'", icpp_replace_all(v, "'", "''"), "'"); }
char *h(char *v) { return icpp_html_escape(v); }
char *field(char *name, int maxlen) { return icpp_substr(icpp_trim(icpp_getparam(name)), 0, maxlen); }
int is_post(void) { if (icpp_strcmp(icpp_getenv("REQUEST_METHOD"), "POST") == 0) return 1; return 0; }
void app_header(char *line) { ... as above ... }
void app_redirect(char *url) { ... as above ... }
int app_db(void) { return icpp_sql_connect(icpp_getenv("ICPP_DB_HOME"), "shop"); }
void app_exec(int db, char *sql) { $r = icpp_sql_query(db, sql); icpp_sql_free($r); }
char *app_one(int db, char *sql, char *col)
{
$v = "";
$r = icpp_sql_query(db, sql);
if (icpp_sql_next($r)) $v = icpp_trim(icpp_sql_get($r, col));
icpp_sql_free($r);
return $v;
}
void page_head(char *title) { iprint("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>", h(title), "</title><link rel=\"stylesheet\" href=\"/site.css\"></head><body>"); }
void page_foot(void) { iprint("</body></html>"); }
A page that handles a form:
<?icpp
#include "../lib/app.c"
int db;
db = app_db();
if (is_post())
{
$name = field("name", 60);
if (icpp_strlen($name) > 0)
app_exec(db, icpp_concat("insert into things (thing_id, name, made_utc) values(",
icpp_atoi(app_one(db, "select thing_id from things order by thing_id desc;", "thing_id")) + 1,
", ", q($name), ", '", icpp_date_string(), "');"));
icpp_sql_disconnect(db);
app_redirect("/");
}
page_head("Things");
?>
<form method="post" action="/"><input name="name" maxlength="60" required><button>Add</button></form>
<?icpp
$r = icpp_sql_query(db, "select thing_id, name from things order by thing_id desc;");
while (icpp_sql_next($r)) iprint("<p>", h(icpp_trim(icpp_sql_get($r, "name"))), "</p>");
icpp_sql_free($r);
icpp_sql_disconnect(db);
page_foot();
?>
(The id allocation above is the short form for a demo; a real site uses a counter table under a lock, rule 4.7.)
6. Builtins you will use on every page
| Need | Builtin |
|---|---|
| Print into the page | iprint(a, b, ...) (newline appended) |
| Build a string | icpp_concat(a, b, ...) |
| Compare, length, find | icpp_strcmp(a, b) (0 = equal), icpp_strlen(s), icpp_strstr(hay, needle) (-1 = absent) |
| Slice, trim, case, replace | icpp_substr(s, start, len), icpp_trim(s), icpp_upper(s), icpp_lower(s), icpp_replace_all(s, from, to) |
| Regex match / first substitution | icpp_re(s, "pattern"), icpp_re(s, "s/pat/rep/") |
| Split / lists | icpp_split(s, sep) -> list; icpp_list_new/add/get/count/free |
| Maps, sets | icpp_map_new/set/get/has/free, icpp_set_new/add/has |
| Numbers | icpp_atoi(s); icpp_concat("", n) |
| Escape for HTML | icpp_html_escape(s) |
| Request | icpp_getparam(name), icpp_getenv(name) |
| Database | icpp_sql_connect(home, db), icpp_sql_query(db, sql), icpp_sql_next(r), icpp_sql_get(r, col), icpp_sql_free(r), icpp_sql_status(db), icpp_sql_disconnect(db) |
| Files | icpp_file_read(path) (about 4 KB), icpp_file_write(path, text), icpp_file_exists(path), icpp_glob(pattern) |
| Time | icpp_time() (epoch seconds), icpp_date_string() ("YYYY-MM-DD HH:MM:SS", local) |
| Random, hashing | icpp_random_hex(nbytes), icpp_sha256(s), icpp_hmac_sha256(key, s) |
| HTTPS | icpp_https_get(url, headers), icpp_https_post(url, headers, body) |
| JSON | icpp_json_get(json, "a.b.0.c") |
| QR and barcodes | icpp_qrcode_svg(text, px), icpp_barcode_svg(text, "code128" or "ean13", px), icpp_qrcode_read(path), icpp_barcode_read(path) |
| Stop | icpp_exit(code) |
The complete list (253 builtins with signatures) is section 4 of the ICPP User Manual.
7. Testing a site the way the interpreter is tested
Write a shell script that starts the server, makes requests with curl and greps the bodies. A check is one line:
get() { curl -s -b "$T/$1" -c "$T/$1" -o "$T/body" -w "%{http_code}" "$B$2" > "$T/code"; }
has() { grep -q -- "$2" "$T/body" && echo "ok $1" || echo "FAIL $1 (no '$2')"; }
get anon /; has "front page lists the seeded thing" "First thing"
Rules that save hours:
- A fresh database per suite. Suites that share one database fail for reasons that look like bugs (a test changed a password, suspended a user).
- Grep uses basic regex: a
*or.in the expected text needs\*,\.. - A page that dies mid-render can still answer HTTP 200 with a short body. Check content, not only status.
- Compile errors are printed by kcpp and name the generated file and a line; the Runner shows them in the browser, an Apache host logs them.
- Same-second timestamps sort in arbitrary order; find a row by a key you chose, not by "the first one on the page".
8. Where things run
| Host | How a page is run | Headers file variable | Notes |
|---|---|---|---|
| ICPP Web Suite (Windows) | iwebserver -> icpptpl -> kcpp | ICPP_HEADERS_FILE | iweb, http://localhost:8098/ |
| ICPP Runner (Android) | Java server -> icpptpl -> kcpp | ICPP_HEADERS_FILE and NOUHATA_HEADERS_FILE | site zip installed from the Sites page; env.txt read; db/*.sql run once |
| Apache on Linux | Action handler (nouhata-cgi) -> icpptpl -> kcpp | NOUHATA_HEADERS_FILE | one process per request; MaxRequestWorkers x ~40 MB per render |
| Command line | kcpp -icpp page.icpp or icpptpl page.icpp.html | (none) | QUERY_STRING and REQUEST_METHOD from the environment |
The database home is wherever the host says: ICPP_DB_HOME in the Runner, whatever env.txt or the server configuration sets elsewhere. Read it from the environment, never hard-code a path.
9. A first prompt that works
Build an ICPP site in this folder. Read ICPP_FOR_AI_AGENTS.md first and follow every rule in it. Pages go in public/, helpers in lib/app.c, the schema in db/001.sql. Start with a front page that lists rows from one table and a form that adds one, then run it with
iweb(or install the zip in ICPP Runner) and fix what kcpp reports before adding anything else.
Keep the first page small, get it to render, and grow from there. The interpreter reports the line of a mistake; the rules above tell you what it usually is.