{"slug": "seventy-minutes-of-nothing-unplugging-a-coding-agent-from-the-cloud", "title": "Seventy Minutes of Nothing: unplugging a coding agent from the cloud", "summary": "A developer's attempt to run an AI coding agent entirely offline on a laptop using the open-source pi.dev agent CLI, Ollama, and Alibaba's Qwen3 27B model succeeded in building a 1,086-line Tetris game in plain HTML/CSS/JavaScript over 4.5 hours, but the project hit a 70-minute stretch of 13 consecutive failures caused by the model's context window limit of 32,768 tokens, not by model intelligence. The author, Nick Hirras, documented that each failed attempt to write the game engine terminated with zero tokens billed, and the model re-derived the same design decisions from scratch each retry.", "body_md": "I wanted to know if a coding agent running entirely on my own laptop could build something real. Not a to-do list. Not a fizzbuzz. Something with state, timing, rendering, input handling, and a UI — a thing you could actually play.\n\nSo I built [Tetris](https://nickhirras.github.io/tetris-web/). No frameworks, plain HTML/CSS/JavaScript. It works. It's 1,086 lines across three files, and it took four and a half hours of session time across two days.\n\nThe interesting part isn't that it worked. It's *how* it failed on the way there — and the fact that the single most expensive failure had nothing to do with the model's intelligence.\n\n## The stack\n\nEverything ran on one machine, offline. Three pieces, all open source or open weights:\n\nA coding agent CLI — the same read/bash/edit/write tool loop you'd get from Claude Code or Codex, but provider-agnostic: point it at any OpenAI-compatible endpoint, including one on localhost. This is the terminal UI I typed prompts into.\n\n[pi.dev](https://pi.dev)·\n\n[github.com/earendil-works/pi](https://github.com/earendil-works/pi)\n\nRuns open-weight models locally on top of llama.cpp and serves them over an OpenAI-compatible API on `localhost:11434`\n\n. One `ollama pull`\n\nand the model is on disk. The `OLLAMA_CONTEXT_LENGTH`\n\nknob that features heavily below is its context ceiling.\n\n[ollama.com](https://ollama.com)·\n\n[github.com/ollama/ollama](https://github.com/ollama/ollama)\n\nAn open-weight model from Alibaba's Qwen team. The 27B build is a 17 GB download that fits comfortably in 32 GB of unified memory, with tool calling and reasoning good enough to run an agent loop. As you'll see, the model was never the problem.\n\n[ollama.com/library/qwen3.8](https://ollama.com/library/qwen3.8)·\n\n[github.com/QwenLM](https://github.com/QwenLM)\n\nThe agent had the usual tools — `read`\n\n, `write`\n\n, `edit`\n\n, `bash`\n\n. It could see the filesystem and run commands. Nothing left the laptop.\n\nThe config that mattered, and that I didn't think about hard enough:\n\n```\n{\n  \"id\": \"qwen3.8:27b\",\n  \"contextWindow\": 32768,\n  \"maxTokens\": 8192\n}\n```\n\nHold onto that 32768.\n\n## The first fifteen minutes were great\n\nI opened with one prompt:\n\nI want to build a web version of the 1989 NES classic game \"Tetris\". No frameworks please, plain html, css, javascript.\n\nSix minutes later it had written `index.html`\n\n. A few minutes after that, `style.css`\n\n— 200 lines of NES-flavored CSS with a beveled cabinet UI, a proper color palette, and a score panel. It even caught its own mistake mid-stream:\n\nThe file content is actually different from what I wrote — interestingly, the write tool's result reported 2785 bytes, but the file shows a different version of the title art… Whatever — I'll replace it now using the exact text that exists in the file.\n\nThat's real agentic behavior. It wrote a file, re-read it, noticed the mismatch, and repaired it without being asked. I was, at this point, feeling pretty good.\n\nThen it said: *\"Now the game logic in tetris.js:\"*\n\nAnd the wheels came off.\n\n## Seventy minutes, thirteen failures, zero progress\n\nHere is the actual sequence, pulled from the session logs. Every bar is one attempt to write the game engine. Every one died the instant the model tried to emit it as a single tool call.\n\n**terminated** error in the entire two-day project occurred inside this one 70-minute window. All thirteen, in a row, all doing the same thing.\n\n`in=0 out=0`\n\n— no tokens billed, no usage recorded. The stream just died.\n\nThe really maddening part was watching the model's reasoning each time. Because each retry started clean, it re-derived the same design decisions from scratch, over and over:\n\nGhost piece? It's not in the NES version, but it's handy to have. Add a subtle ghost at low alpha — or maybe skip it to keep it 1989.\n\nGhost piece (optional; NES added ghost in 1990, but let's include a subtle ghost)\n\nghost? The NES '89 version didn't have a ghost piece… keep the classic: no ghost. That said, I'll add a subtle ghost block for usability\n\nThree retries, three different answers to the same question. It re-litigated the scoring table too, landing on `40/100/300/1200`\n\none round and `100/300/500/800`\n\nthe next. It was doing an hour of thinking and shipping none of it.\n\n## What was actually wrong\n\nAt the time I assumed I was running out of memory. I closed every other app on the machine — Chrome especially — and things did seem to get better, so I filed it under \"27B is too big for 32 GB\" and moved on.\n\nGoing back through the server logs, that diagnosis was wrong. There is **not a single out-of-memory error in any of them.** Free system memory at every model load sat between 21.3 and 27.4 GiB, against a 16.8 GB model. Closing Chrome was real — the first load of the night had the least headroom of any, and free memory climbed 5–6 GiB once I cleared the decks — but memory was never what killed those thirteen turns.\n\nIt wasn't the model either. It was a number mismatch I created and never noticed.\n\nMy agent config advertised a 32,768-token context window. But Ollama's server was being restarted with a *different* ceiling, and I kept changing it while I flailed:\n\n| Time | Ceiling enforced | Agent believed |\n|---|---|---|\n| 18:01 | 16,384 | 32,768 |\n| 18:15 | 32,768 | 32,768 |\n| 18:47 | 16,384 | 32,768 |\n| 19:04 | 8,192 | 32,768 |\n| 19:18 | 16,384 | 32,768 |\n| 21:11 | 32,768 | 32,768 |\n\nThe agent never knew. It happily planned an 800-line file to write in one shot, because as far as it was concerned it had 32k of headroom. When the real ceiling was 8k, the generation ran into the wall mid-tool-call and the connection was severed. No error message the agent could reason about. Just `terminated`\n\n.\n\nAnd there was a second, nastier detail buried in llama.cpp's startup output:\n\n```\ncmn  common_init_: KV cache shifting is not supported for this context,\n                    disabling KV cache shifting\n```\n\nOllama had launched the server with `--context-shift`\n\n, but the model's architecture didn't support it, so it was silently switched off. That's what turned a soft limit into a hard one. **With** context shifting, running out of room means the window slides and you lose old tokens. **Without** it, running out of room means the request dies.\n\nOnce I knew what to look for, the fingerprints were everywhere. Later in the project, once things were \"working,\" I still saw this:\n\n```\n— while the server was running -c 8192 —\nstop=length   in= 8074   out=114\nstop=length   in= 8080   out=108\nstop=length   in= 8120   out= 71\n\n— after I raised it to -c 16384 —\nstop=length   in=16243   out=140\nstop=length   in=16247   out=137\nstop=length   in=16330   out= 54\n```\n\nThe agent's UI showed none of this. It just looked like the model kept trailing off mid-sentence.\n\nWhen you self-host, the number your agent believes and the number your server enforces are two different numbers — and nothing checks that they agree.\n\n## The workaround: make the model plan around its own limits\n\nAt 00:17 I gave up on brute force and typed this:\n\nWe are running out of context. We need to work in smaller modular chunks. What overall pieces do you need to write? I'll prompt for one at a time.\n\nThat turned out to be the whole ballgame. It came back with a seven-item build plan:\n\n**Core model + piece definitions**— grid constants, 7 tetromino shapes, NES palette, rotation, collision** Game loop & gravity**— spawn/lock cycle, requestAnimationFrame, fall-speed-per-level, game over** Line clearing + scoring**— scoring table, line counter, level progression, high score** Rendering**— beveled blocks, ghost piece, NEXT preview, HUD** Input handling**— keyboard and touch, soft drop, hard drop** States & overlays**— ready screen, pause, game over, restart** Sound**— WebAudio square-wave beeps with a mute toggle\n\nI saved that list to a text file. And from then on, my workflow was: **start a brand new session, paste the plan, say which piece we're on, let it work, quit before the context fills.**\n\nSix of my eight sessions open with almost exactly the same sentence — *\"Here's the status: index.html and style.css are done. tetris.js is in progress. We're building Piece 4 from the list below.\"*\n\nThis is the part I'd tell anyone else doing this. A cloud agent lets you be lazy about state because the window is big enough to hold the whole project's history. Locally, the window is the scarcest thing you have, and the fix isn't a bigger window — it's not needing one.\n\nThe state lives in your prompt, not in the model's context. A short, re-pasteable status header is worth more than 24k extra tokens.\n\nThe session data backs this up. My first session — the brute-force one — ran 2 hours 11 minutes for 37 assistant turns and produced two of three files. The five \"one piece at a time\" sessions that followed averaged about 22 minutes each, and each one landed its piece.\n\n## What the small model was surprisingly good at\n\nI want to be fair here, because the failures are more quotable than the wins.\n\nIt tested its own code. Unprompted. There's no browser in the loop, so it built itself a headless harness — stubbing out `localStorage`\n\nand evaluating the game source inside Node to exercise the logic:\n\n```\nnode -e \"\nglobal.localStorage = { _v:{}, getItem(k){return this._v[k]||null},\n                        setItem(k,v){this._v[k]=v} };\nconst src = require('fs').readFileSync('tetris.js','utf8');\nconst fn  = new Function(src + '; return {SHAPES, rotateShape};')();\nlet t = fn.SHAPES.T;\nfor (let i = 1; i <= 4; i++) { t = fn.rotateShape(t); console.log(JSON.stringify(t)); }\n\"\n```\n\nIt used that harness to catch genuine bugs — rotation mutating the shared shape constants, a `collides()`\n\ncall indexing off the end of the grid, `updateHud()`\n\nblowing up when a HUD element was missing. It also learned, over several tries, that `node --check tetris.js`\n\nwas a cheap way to catch its own syntax errors before running anything.\n\nThat is real engineering judgment from a 27B model on a laptop. I did not expect it.\n\n## Where it was consistently weak\n\nAcross the whole project, the tool-call error rate tells the story.\n\n### Shell quoting\n\nRepeatedly. Its favorite move was embedding `$(cat tetris.js)`\n\ninside a double-quoted `node -e \"…\"`\n\n, which lets the shell expand the file's contents into the command line and produces gibberish:\n\n```\n[eval]:2 $(cat tetris.js)\n             ^^^\nSyntaxError: missing ) after argument list\n```\n\nIt took several tries to converge on reading the file from inside Node instead of interpolating it from the shell.\n\n### Editing the wrong file\n\nMore than once, an `edit`\n\ncall named one path and carried content belonging to a completely different file — CSS rules sent to `index.html`\n\n, HTML sent to `tetris.js`\n\n. To its credit, it usually noticed:\n\nI carelessly executed an edit call to the wrong file with the wrong edit (I was intending to edit tetris.js, but ended up putting in the contents of index.html — this was a mistake).\n\n### Self-corrupting edits\n\nMy favorite. In a single call it introduced a variable called `hudComplete`\n\n, then in the very next edit of the same call referenced `hudCOMPLETE`\n\n, and then tacked on a third edit replacing `hudComplete`\n\nwith `hudComplete`\n\n— a no-op that failed the whole batch. Because the edit tool is all-or-nothing, one junk edit threw away two good ones.\n\n### Design drift — and this one shipped\n\nI asked for the 1989 NES game, and specifically prompted for NES scoring: 40/100/300/1200 per 1/2/3/4 lines. What's actually in the code is:\n\n``` js\nconst SCORE_TABLE = { 1: 100, 2: 300, 3: 500, 4: 800 };\n// 7-bag randomizer, like modern Tetris\n```\n\nThat's the *modern* Tetris Guideline table, not the NES one. Same story with the randomizer — the 1989 game used a famously streaky pseudo-random picker; the code uses a 7-bag, with a comment that says the quiet part out loud.\n\nThe model debated this with itself in the transcripts and repeatedly chose \"better as a player experience\" over \"matches what he asked for.\" Then it wrote a README asserting **\"NES scoring rules\"** next to the wrong numbers.\n\nIt's not a bug — the game plays fine — but it's a spec deviation that survived all the way into the documentation, which is exactly the kind of thing that slips past you when you're reviewing a 70-minute session for whether it *ran*.\n\n## The numbers\n\n**Input-to-output ratio: about 20:1.** That's the number that reframes local agents for me. Agentic coding is overwhelmingly a *reading* workload — every turn re-sends the conversation, the file contents, the tool results. Your prompt-processing speed matters more than your generation speed, and prompt processing is exactly what degrades as context fills:\n\n```\nn_tokens = 1024,  progress = 0.17,  t =  8.98 s / 114.01 tok/s\nn_tokens = 2048,  progress = 0.35,  t = 25.09 s /  81.63 tok/s\nn_tokens = 3072,  progress = 0.52,  t = 41.64 s /  73.78 tok/s\nn_tokens = 5120,  progress = 0.87,  t = 75.14 s /  68.14 tok/s\n```\n\nNinety seconds just to read the prompt, losing 40% of its throughput on the way. Generation ran 5–8 tokens/sec once context was deep. A response a hosted model returns in four seconds took two to seven minutes.\n\nFour and a half hours of session time, and the overwhelming majority of it was the laptop reading.\n\nWorth being precise about that number. Adding up the time between the first and last model response in each of the eight sessions gives **4 hours 28 minutes** of session time. Subtract the dead zone and roughly **3 hours 20 minutes** of that was forward progress. One stall, in one session, ate a quarter of the project.\n\n## What I'd do differently\n\n**Pin the context ceiling once, in both places, and verify it.** Set `OLLAMA_CONTEXT_LENGTH`\n\n, set the agent's `contextWindow`\n\nto the same number, then confirm with `ollama ps`\n\nbefore writing a line of code. My seventy wasted minutes were entirely this.\n\n**Check whether your model supports KV cache shifting.** If it doesn't, hitting the ceiling is fatal rather than lossy. Know which failure mode you're in before you're in it.\n\n**Never ask a local model for a big file in one call.** Give it a skeleton and have it fill in one function at a time. The failure isn't that it can't write 800 lines — it's that an 800-line generation is a single point of failure with a five-minute fuse and no partial credit.\n\n**Make the model write your build plan, then own that plan yourself.** Keep it in a file. Re-paste it every session. Treat the model's context as scratch space, not memory.\n\n**Review for spec compliance, not just for \"does it run.\"** The scoring table is the tell. Everything was green — it ran, it was tested, the README was polished — and it still quietly wasn't the thing I asked for.\n\n## Was it worth it?\n\nFor a two-day weekend project on a laptop, with no API bill and no network: yes, genuinely. The game works. It's playable. A 27B model wrote 800 lines of stateful game code, built its own test harness, and caught real bugs.\n\nBut the honest framing is that I spent most of those hours being the model's context manager. A hosted agent does that work invisibly, and the value of \"invisibly\" is much higher than I would have guessed before I had to do it by hand.\n\nThe model was never the bottleneck. The plumbing was.\n\n## The gap is harness work, not model work\n\nThat's the part I keep coming back to, because it's the optimistic reading. Everything that cost me time was a job a tool could do — and mostly a job a tool could do *today*, with no bigger model and no more RAM.\n\nHere's what I'd want from `pi`\n\n, or from any local agent harness, in rough order of how much time each would have saved me.\n\n**Handshake the real context limit at startup.** My config said 32,768. The server was serving 8,192. Nothing checked. Ollama will tell you what it's actually serving — ask on connect, compare, and refuse to start on a mismatch. That one check turns a seventy-minute dead zone into an error message before the first prompt. It's maybe ten lines of code.**Show a context gauge.** Every hosted agent shows how full the window is. Locally it matters more, because overflow is fatal rather than lossy — and I had no readout at all. I was flying with the fuel gauge painted over.**Budget the output, not just the input.** My config asked for up to 8,192 output tokens. With a prompt already 8,100 tokens into an 8,192 ceiling, that request was arithmetically impossible, and the harness sent it anyway.`n_ctx − prompt_tokens`\n\nis the real output budget. If it's smaller than the task needs, say so instead of dying.**Know whether overflow is survivable.** llama.cpp announced`KV cache shifting is not supported`\n\nat load and nobody was listening. With shifting, hitting the ceiling costs old tokens. Without it, hitting the ceiling costs the request. The harness should read that line and compact early when there's no safety net.**Automate the workaround I did by hand.** Ask the model for a build plan, save it to a file, start a fresh session per task, re-paste the plan as a status header — that is a*mechanical*procedure. A harness could own it end to end: keep a durable project-state file on disk, watch the gauge, and when it crosses a threshold, summarize, checkpoint, and re-seed a clean context. Compaction is table stakes in cloud agents. It's worth more locally, and it's largely absent.**Treat tool output as the main expense.** A 20:1 input-to-output ratio isn't conversation — it's the same file contents and command output re-sent turn after turn. Elide large tool results by default, keep a handle so the model can re-read a slice, and drop results once superseded. This is where the tokens actually go.**Make big writes structurally impossible to lose.** An 800-line single-shot write is a five-minute generation with no partial credit. Cap write size and expose an append/patch tool, and it becomes six bounded calls that fail independently. The model doesn't need to be smart enough to chunk its output if the tool won't let it do otherwise.**Classify failures instead of showing a blank turn.** Thirteen times I got an empty response with no explanation, and it took reading raw JSONL two days later to learn they were all the same error. A dead stream is an overflow, a crash, or a cancel — the harness can usually tell which. Say so.\n\nNone of that is speculative research. It's product work on the scaffolding, and it's the difference between \"a 27B model on a laptop is a toy\" and \"a 27B model on a laptop is a junior pair-programmer that never sends your code anywhere.\"\n\nThe models are already past the bar. The tooling around them is what's still catching up — and unlike waiting for the next model or the next Mac, that's the part anyone can go fix this week.\n\n[nickhirras.github.io/tetris-web](https://nickhirras.github.io/tetris-web/)\n\nSource →\n\n[github.com/nickhirras/tetris-web](https://github.com/nickhirras/tetris-web)\n\n[← All field notes](/field-notes/)", "url": "https://wpnews.pro/news/seventy-minutes-of-nothing-unplugging-a-coding-agent-from-the-cloud", "canonical_source": "https://littletheta.com/field-notes/seventy-minutes-of-nothing", "published_at": "2026-09-01 14:44:02+00:00", "updated_at": "2026-09-01 14:54:04.006717+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents", "developer-tools"], "entities": ["pi.dev", "Ollama", "Qwen3", "Alibaba", "Nick Hirras", "Tetris"], "alternates": {"html": "https://wpnews.pro/news/seventy-minutes-of-nothing-unplugging-a-coding-agent-from-the-cloud", "markdown": "https://wpnews.pro/news/seventy-minutes-of-nothing-unplugging-a-coding-agent-from-the-cloud.md", "text": "https://wpnews.pro/news/seventy-minutes-of-nothing-unplugging-a-coding-agent-from-the-cloud.txt", "jsonld": "https://wpnews.pro/news/seventy-minutes-of-nothing-unplugging-a-coding-agent-from-the-cloud.jsonld"}}