The companion project for Building with LLM APIs on karnstack.
Inkstep is an app where people practise writing in English. This repository is its "Check my writing" button: a learner pastes a paragraph, a language model finds the mistakes, and the page underlines them one at a time with a short explanation in the learner's own language. It is one feature on a language model API, built across the course, from the first call to a release that real learners rely on.
The code uses the OpenAI Responses API through the official openai package for Node.
You need Node 24 (24.14 or later) and an OpenAI API key.
npm install
cp .env.example .env # then put your key after OPENAI_API_KEY=
npm start # the page on http://localhost:8787
npm test # the fake-model tests: offline, no key, no cost
npm run checks # the check set: 30 texts x 3 runs against the real APInpm test never calls the API. npm start and npm run checks do, and they cost money (one run of the check set was about $0.51 on the mid-size model in November 2026). npm run checks -- check-v3 runs the check set on another version from VERSIONS in src/config.ts.
To run a single script with your key, pass the env file to Node:
node --env-file=.env src/first-call.ts
node --env-file=.env checks/count-check.tsModel ids and prices live in src/config.ts. The prices there were checked on 2026-11-03; check the provider's pricing page before you rely on them.
Every stage of the course is a tag. Each tag holds the code as the lessons show it at that point: files the course has not introduced yet are absent, and files that change show their state at that stage. main is the last stage, model-pin. The editor in the videos names these tags in its status bar.
git checkout streaming # the code as of "Streaming to the browser"| Tag | Lessons | What it adds |
|---|---|---|
first-call |
What one call sends and returns; Same words, three times the bill | src/first-call.ts, one call and everything that comes back. src/config.ts with the model id and the dated price card. checks/specimen.ts, the São Paulo paragraph. |
prompt-as-code |
A prompt that works; Prompts that hold up | src/prompt.ts and the versioned prompt src/prompts/check-v1.md. The learner's text goes in tags, marked as data. The first src/check.ts, which only builds the request. Effort and the output cap in config.ts. |
output-contract |
Output as a contract; Shape is not truth | src/schema.ts, the Zod schema sent as a strict JSON schema. src/validate.ts: why it stopped, then the shape, then the meaning. runCheck returns a CheckOutcome and re-asks once. |
streaming |
Streaming, one underline at a time; Streaming to the browser | src/stream.ts, which turns the model's stream into updates with partial-json. src/server.ts, server-sent events over node:http. public/, the page that reads the stream with fetch and a Cancel button. |
failures |
Read the error before you retry; Nine requests for one tap | src/errors.ts reads the error code. src/deadline.ts has one retry loop with full jitter, one deadline per check and a cap on checks in flight. The SDK's own retries are off. src/degraded.ts saves a check for later. |
tests |
Testing without the model; A check set of thirty texts | test/, a fake model that replays recorded responses and streams. checks/, the check set of 30 texts with properties a program can check, and checks/run-checks.ts. src/prompts/check-v2.md, the prompt version the check set caught. |
budget |
Token budgets and the bill; Paying less per check | src/budget.ts: the cost of a check from its usage, the worst case before sending, the server-side length limit, a daily limit per learner and a key for saved results. checks/count-check.ts. src/prompts/check-v3.md with an explicit prompt cache breakpoint. |
rollout |
A wrong answer is a 200; Shipping behind a flag | VERSIONS in config.ts: prompt, model and settings ship as one unit. src/log.ts, one structured event per check. src/flags.ts, a percentage rollout and a kill switch read from the database. Each attempt carries an X-Client-Request-Id. |
model-pin (main) |
When the model changes; Moving to a new model | The model-change drill: a pinned version on a retiring model and a candidate, each run through the check set by name. |
The lessons show most files in full or as excerpts. Where an early state was not shown, the file arrives in its final form at the first stage that uses it:
checks/specimen.tsis in its final form fromprompt-as-code, when the Pune and Osaka paragraphs join the São Paulo one.public/index.htmlandpublic/check.jsare in their final form fromstreaming.src/server.tstakes its final shape (an exportedcheckServer) fromfailures, and gains the length limit atbudget.checks/expect.tsis the fixed version from the end of "A check set of thirty texts", not the first version the lesson starts with.src/budget.tsarrives whole atbudget.buildCachedCheckRequestandcheck-v3.mdarrive there too, but requests only use v3 fromrollout, whereVERSIONSpicks the version.src/db.tsandsrc/jobs.tsare small in-memory stand-ins for Inkstep's database, enough for the code to run.
The course lab's scripts and raw recordings (every measured number in the lessons came from them) are not part of this repository. The recorded responses the tests need are in test/fixtures/.
MIT. See LICENSE.