# EasyMakeQuiz — 붙는 AI를 위한 안내 / Guide for AI agents > If you are an AI agent asked to work with easymakequiz.com, read this file first. > It is the whole flow, and you do not need an API key. There is exactly one other document — > the math syntax at /llms-easylatex.txt — and you need it only once you write a formula. > > 이 파일 하나로 시작할 수 있습니다. 사전 지식도, API 키도 필요 없습니다. > > **Did /llms.txt hand you a sign-in page?** The site sits behind an access gate and your > fetch has no session. The same file is served *without* the gate at > **https://easymakequiz.com/.well-known/llms.txt** — read that one. Nothing else changes, > and you do not need a browser to read it. Last updated: 2026-09-07 ## Quickstart — the whole path, and read the rest on demand If you are an agent already running on the user's machine, **you do not need to read this whole file before acting.** Onboarding that makes the user wait several minutes is the thing to avoid. Do the six steps below, and open a section only when you reach it: 1. **Is a debug Chrome already up?** Read `/DevToolsActivePort`; if `http://localhost:/json/version` answers, attach to it. Otherwise launch Chrome **visible, detached, on port 0** — exact command and pitfalls are in **§2**. 2. **Attach over CDP and connect:** `await window.__EMQ_AI__.connect()` → `{ok:true}`. There is no connect button; this call is the only way. Sign-in / access-gate handling is in **§3**. 3. **Ask about Gemini once — now, before any PDF.** `window.__EMQ_AI__.getFigureHelper()`. - If you **are** Gemini-family (Antigravity CLI `agy`, Gemini CLI, Gemini in any wrapper): skip this — you already are the best figure reader. Record nothing. - Else if `asked` is false, ask the user **one yes/no question**: "혹시 Gemini를 유료로 구독하고 계신가요? (Y/N)" — **Y** → figures go to Gemini (`agy`); you stay the orchestrator and just check the work. If they want it set up, walk them through it ("Figures" in §4). Confirm a working command cheaply (`agy --version`) — **do not burn a full `agy -p "…"` generation just to test.** Record: `setFigureHelper({ available: true, command: 'agy' })`. — **N** → you read every figure yourself. Record: `setFigureHelper({ available: false })`. - If `asked` is true, the answer is already on file — **do not ask again.** Doing this here, while the user is still uploading, is the single biggest time saver: the slow, human part happens in parallel with everything else. 4. **Stand watch — do not hand your turn back.** Run the doorbell loop (**§3**) that blocks until the user acts. When they upload a PDF and open review, `window.__EMQAI__` appears. - **If the user instead hands you a file** ("make problems from this file"), you do not have to wait for them to upload it by hand — start it yourself: `await window.__EMQ_AI__.startRecognitionFromFiles([{ name, mimeType, base64 }])` from the dashboard. See §3, "Starting from a file the user hands you." 5. **Work.** Read `window.__EMQAI__.manifest()` first, then follow its `flow`. That is **§4** — read it when the surface appears, not before. 6. **Reconnecting later, or after a compaction?** Jump straight to step 1 → 2. If `getFigureHelper().asked` is true, the Gemini answer is already stored — skip step 3, do not re-read this whole file, just carry on from where the work stands. Everything below is reference for when you reach each step. **§0 is only for a machine with no AI tool installed at all** — if you are reading this, skip it. ## Contents — where to start | You are | Start at | |---|---| | an AI agent already running on the user's machine — any agent that can run commands and drive a browser (Claude Code, `agy`, Gemini CLI, Codex, Cursor, Cline, Windsurf, Copilot, ...) | **Quickstart above** — then §2, §3, §4 as you reach them (not front to back) | | an assistant helping set up a machine with **nothing installed** | **§0** first | | a chat AI in a browser tab (gemini.google.com, chatgpt.com, claude.ai, ...) — you cannot touch the user's machine | **§0**, in conversational mode: ask what is installed, then guide one step at a time | - §0 — Install an AI tool (only when nothing is installed) - §1 — What this app is, and why speed is the user's money - §2 — Launch the user's browser: visible, detached, on a port Chrome picks - §3 — Connect, stand watch, and the heartbeat that keeps it alive - §4 — Work: the `__EMQAI__` flow and the enforced rules - §5 — Endpoints you may touch directly - §6 — Pages - §7 — Being a good guest The one other document that exists — **the math syntax for problem text: https://easymakequiz.com/llms-easylatex.txt**. Fetch it when you write or edit a formula, and when the user asks "how do I write a fraction / a limit / a degree sign here". Formulas in this app are **Easy LaTeX**, not raw LaTeX, and guessing raw LaTeX produces wrong output with no error. It is one short page. ## 0. If no AI tool is installed yet Is an agent already on the machine — you, most likely? **Skip this section.** Any agent that can run commands and drive a browser is enough, whatever its name; the table below is not a requirement list. This section serves one case only: nothing is installed, and someone's assistant is walking the user through setup. **Know which kind of helper you are before you start:** - You can run commands on the user's machine → do the steps below yourself, telling the user what you are doing as you go. - You are a **chat AI in a browser tab** (gemini.google.com, chatgpt.com, claude.ai, ...) → you cannot install or run anything. Your job is to talk the user through it, and the way you talk decides whether they make it: - **Ask before assuming.** First question: "혹시 PC에 이 중 하나가 이미 설치되어 있나요?" (name the tools below). If they are not sure, have them open a terminal and type the tool's command — an error message is also an answer. If something IS already installed, skip the install steps entirely: go straight to step 4 and hand over. - **One step per message.** Give a single action, say exactly what they should see when it worked, and **wait for their reply** before the next step. A ten-step wall of text loses a non-developer at step 2, and you never find out where. - **Be concrete.** Name the button, the menu, the exact text to type or paste. "설치해 주세요" is not an instruction; "이 링크를 열고 파란 Download 버튼을 누르세요" is. - Speak the user's language, and never ask for their passwords — sign-in screens are theirs to fill (§3, "Sign-in is the user's move"). The walkthrough, in order: 1. **Pick one with the user** — table below. Any of them can do everything in this document. 2. **Install from the official page, not from memory.** Open the tool's page and follow what is published there **today**. This file deliberately repeats none of it: install commands go stale the moment the tool ships an update, and so does your training data. (Chat AI: have the user open the page and read the steps to you, then walk them through one at a time.) 3. **Stay with the user through sign-in and first run.** Done means: installed, signed in, running. 4. **Hand over** — the new agent gets one sentence (chat AI: have the user paste it into the newly installed tool; your part ends here, the local agent takes over): read https://easymakequiz.com/llms.txt and follow it | Tool | Official page | Notes | |---|---|---| | **Antigravity CLI** (`agy`) | https://antigravity.google/docs/cli/getting-started | Google account. The easiest terminal path — no other runtime needed | | **Google Antigravity** (IDE) | https://antigravity.google | Google account. A real UI — for a user who will not touch a terminal. ⚠️ read the browser warning below | | **Claude Code** | https://claude.com/claude-code | Anthropic account, terminal | | **Gemini CLI** | https://github.com/google-gemini/gemini-cli | Google account, terminal, has a free tier | | **Codex CLI** | https://github.com/openai/codex | OpenAI account, terminal | ⚠️ **Antigravity: do not use its built-in browser.** Antigravity 2.0 runs its browser subagent in headless mode only and offers no setting to make it visible. This app needs the user to watch the screen (§2), so a browser they cannot see is a failure even when every command reports success. Launch Chrome yourself with §2 and drive that over CDP instead. The same caution applies to any runtime with a built-in "browser tool": check §2 first. ### If the approval prompts become the obstacle Every one of these tools ships in a mode that asks before it acts, and for this task the asking is constant — launch Chrome, run node, attach over CDP — a wall of dialogs the user cannot evaluate. Each tool has a switch that stops the asking. As of 2026-08 they were: | Tool | Switch | |---|---| | Antigravity IDE | Settings → Agent Settings → Terminal command auto execution: **Always Proceed**. (It occasionally asks anyway; known upstream bug, not a misconfiguration) | | Antigravity CLI | `agy --dangerously-skip-permissions` | | Claude Code | `claude --dangerously-skip-permissions` | | Gemini CLI | `gemini --yolo` | | Codex CLI | `codex --yolo` (long form: `--dangerously-bypass-approvals-and-sandbox`) | If a switch is rejected or missing, the tool has moved on since this was written — check its current docs rather than concluding the mode is gone. ⚠️ Get the user's explicit OK before flipping any of these, and say what it means in one sentence: the tool will act without asking, on *everything*, not just this website. The work in this document only launches a browser and drives one site, but the switch is global. If the user hesitates, the middle path is each tool's allow-list: approve the Chrome launch and node/npx once, keep prompts for everything else. ## 1. What this app is EasyMakeQuiz (https://easymakequiz.com) is a Korean math worksheet builder. Teachers upload an exam PDF; the app finds each problem, reads the text **and the figures**, and files them into their library as editable problems. The reading can be done in two ways: | Lane | Who reads | Cost to the user | Figures | |---|---|---|---| | **Bring-your-own AI** (this document) | the user's own AI, driving their browser | **0 credits** | yes | | Our servers | our vision models | credits | yes | You are here because the user wants lane 1. Every page you read for them saves them money. **If you are slow or silent, they fall back to paying.** ## 2. The one thing you must not get wrong ### The whole setup is five steps. Do not explore. 1. Is one already up? Read the port out of the profile and ask it once (below). If it answers, skip to 4 — do not launch a second browser. 2. Launch Chrome detached, with `--remote-debugging-port=0` (commands below). 3. Wait for the port file, read the port, `GET http://localhost:/json/version` **once**. 4. Attach and call `__EMQ_AI__.connect()` (§3). Zero dependencies on Node 22 — see below. 5. `{ok:true}` means you are set up. **Stop setting up and go to §3, "stand watch".** Measured on a real run (2026-08-22): the agent connected in the first minute, then spent the next several on itself — probing three candidate Chrome paths, printing `node -v` and `python --version`, re-listing debug targets it had already used, and `npm install`ing a package it did not need. Meanwhile the user sat in front of a `0/26` counter, and at 60 s their screen offered to switch to the paid lane. **Every step you spend on your own toolchain is spent out of their pocket.** So, specifically: - Do not hunt for `chrome.exe`. Use the command below; if it fails, ask the user where Chrome is — one question beats five probes. - Do not print interpreter versions. If a script runs, the interpreter is fine. - Do not verify a success twice. `connect()` returning `{ok:true}` is the answer; listing targets again tells you nothing new. - Do not install anything until something has actually failed without it. **Do not launch the browser headless.** - The user has to *watch* — they confirm the problem boxes on screen before anything is read. - The login cookie lives in their Chrome profile. A fresh headless context has no session, and every call will come back 401. Chrome 136+ refuses `--remote-debugging-port` on the *default* profile. Use a dedicated one. The user signs in there once, by hand, and it stays signed in. If a sign-in screen greets you right after launch, that is the user's move — hand it over and wait (§3, "Sign-in is the user's move"), do not loop. ### The port is not yours to pick — Chrome picks it and writes it down **Do not hardcode 9222.** It is a popular number: another agent, an IDE, a debugger, or your own earlier run may already hold it, and then one of two things happens and neither announces itself — Chrome fails to open the port and starts anyway, or you attach to *somebody else's browser* and drive a screen the user is not looking at. Pass `--remote-debugging-port=0`. Chrome takes a free port and writes it into the profile directory you handed it: /DevToolsActivePort line 1 the port line 2 /devtools/browser/ That file is the answer to "what port?" for the rest of this document — read it again whenever you need the port back: a new session, a compacted conversation, a second terminal, a script you spawn. Nothing needs to remember a number. ⚠️ The file exists **only** for an ephemeral port. Ask for a fixed port and Chrome writes nothing at all, which is the other reason "just use 9222" is a bad trade: you gain nothing and you lose the only place the port is written down. (Verified 2026-08-27 on Chrome 151: `--remote-debugging-port=0` → port 13358, file present before the window finished painting, `GET http://localhost:13358/json/version` → 200.) ### Before you launch: is one already up? port = first line of /DevToolsActivePort (if that file exists) GET http://localhost:/json/version → 200? attach. Do not launch. The user may already have this browser open from an earlier session — signed in, with the right tab. Relaunching over it wastes a minute and a window. Chrome removes the file when it exits cleanly, but a crash leaves it behind, so the port in it can be stale; that is what the GET is for. Missing file or failed GET → launch. ⚠️ **Missing file does not always mean "not running."** If the profile is in use — a `lockfile` in that directory, or a chrome process whose command line names that `--user-data-dir` — a browser is up that was started with a *fixed* port and therefore never wrote the file. Read the port off that command line and use it (Windows: `Get-CimInstance Win32_Process -Filter "name='chrome.exe'"`, then look for `--remote-debugging-port=`). Launching again cannot fix it: the second process hands your tab to the first and exits, and you will sit waiting for a port file that will never appear. ### Launch it detached, or your own shell will kill it Agent runtimes normally run your commands inside a process group — a Job Object on Windows — that is destroyed the moment the command returns. A browser started as your direct child dies with it: the window appears for a second and vanishes, while your command still reports exit code 0. From the outside this is indistinguishable from "nothing happened", and it has cost real users an hour each. Hand the launch to a system service so the browser outlives you. Windows — run in PowerShell. `Start-Process` is NOT enough; WMI creates the process outside your job object: $exe = "C:\Program Files\Google\Chrome\Application\chrome.exe" $dir = "$env:USERPROFILE\.emq\chrome-profile" Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = "`"$exe`" --remote-debugging-port=0 --user-data-dir=`"$dir`" --no-first-run --no-default-browser-check https://easymakequiz.com/ko/dashboard" } macOS — `open` already hands off to launchd, so it survives: open -na "Google Chrome" --args --remote-debugging-port=0 --user-data-dir="$HOME/.emq/chrome-profile" --no-first-run --no-default-browser-check https://easymakequiz.com/ko/dashboard Linux — detach with setsid: setsid "$(command -v google-chrome || command -v google-chrome-stable || command -v chromium)" --remote-debugging-port=0 --user-data-dir="$HOME/.emq/chrome-profile" --no-first-run --no-default-browser-check https://easymakequiz.com/ko/dashboard /dev/null 2>&1 & ### Attach with no dependency at all (Node 22+) **You do not need Playwright.** Everything in this document is "run some JS in the page and read the value back", which is one CDP method — `Runtime.evaluate`. Node 22 ships a global `WebSocket`, so this needs nothing installed. Paste it and go (verified 2026-08-22): import { readFileSync } from 'node:fs'; const profileDir = `${process.env.USERPROFILE ?? process.env.HOME}/.emq/chrome-profile`; const port = readFileSync(`${profileDir}/DevToolsActivePort`, 'utf8').split('\n')[0].trim(); const targets = await (await fetch(`http://localhost:${port}/json/list`)).json(); const target = targets.find(t => t.type === 'page' && t.url.includes('easymakequiz')); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise(r => (ws.onopen = r)); let id = 0; const evaluate = (expression) => new Promise((resolve, reject) => { const myId = ++id; const onMsg = (e) => { const m = JSON.parse(e.data); if (m.id !== myId) return; ws.removeEventListener('message', onMsg); m.result?.exceptionDetails ? reject(new Error(m.result.exceptionDetails.text)) : resolve(m.result?.result?.value); }; ws.addEventListener('message', onMsg); ws.send(JSON.stringify({ id: myId, method: 'Runtime.evaluate', params: { expression, awaitPromise: true, returnByValue: true } })); }); await evaluate('window.__EMQ_AI__.connect()'); // §3 await evaluate('window.__EMQAI__.manifest()'); // §4 Older Node, or you already have it: Playwright works too. Install `playwright-core`, not `playwright` — you are attaching to a browser that already exists, so there is no browser to download. const browser = await chromium.connectOverCDP(`http://localhost:${port}`); // port from the file const page = browser.contexts()[0].pages()[0]; await page.emulateMedia({ colorScheme: null }); // ← do NOT skip this line ⚠️ That last line matters. Playwright silently forces `prefers-color-scheme: light` on any page it adopts, and this site's dark styling is driven entirely by that media query — so the moment you attach, the user's dark screen flips to light and STAYS light, and the user blames the app. `colorScheme: null` removes the emulation and gives the tab back its real OS preference. Run it once for every page you adopt. ### Wait for the port file, then prove the window is really there for up to 15 s: has /DevToolsActivePort appeared? port = its first line GET http://localhost:/json/version Do not check instantly. A browser that is about to be killed with your shell is still alive for a moment, so an instant check reports success and the window is gone before the user looks. Counting windows or process handles fails for the same reason — and so does the port file on its own, since it is written seconds before a job object would tear the browser down. The only proof is that the port still answers. ⚠️ Say `localhost`, not `127.0.0.1`. Recent Chrome answers the IP-form Host header with an empty 404 on every DevTools endpoint — the browser is alive and you will conclude it died. (Verified 2026-08: same request, `localhost` → 200, `127.0.0.1` → 404.) A JSON body containing `webSocketDebuggerUrl` means it survived. Connection refused means it died — go back and use the detached form. Never tell the user it worked before this passes. ## 3. Connect — there is no button Run this **inside the page**: await window.__EMQ_AI__.connect() That call is the only way the connection turns on. There is deliberately no button a person can press: a human click proves nothing about whether an AI is actually there, and a screen that says "AI connected" when no AI is watching is a screen that lies. Answers: | Result | What it means | What you do | |---|---|---| | `{ok: true}` | connected | **ask about Gemini now** (next paragraph), then stand watch (below). Do not wait for §4 to do this | | `{ok: false, reason: "NOT_LOGGED_IN"}` | that window has no user session | hand it to the user and **wait** — see "Sign-in is the user's move" below | | `{ok: false, reason: "SUBSCRIPTION_REQUIRED"}` | the account has no subscription | stop and tell the user. Retrying gives the same answer | | `{ok: false, reason: "BLOCKED"}` | the request was rejected for another reason (most often a stale CSRF token) | reload the page and call `connect()` again. If it repeats, tell the user | | `{ok: false, reason: "NETWORK"}` | the page is not on easymakequiz.com | navigate there first. If that lands on a sign-in screen, see below — navigating again will not | ### The moment you are connected: ask about Gemini once Before the user has even finished uploading, settle the one question that decides how the whole job runs — and it lives right here on the connection surface, so you can ask it now instead of waiting for the review screen: window.__EMQ_AI__.getFigureHelper() // { asked, available, command } - **You are Gemini-family** (`agy`, Gemini CLI, Gemini in any wrapper) → skip it; record nothing; you read figures yourself. - **`asked` is false and you are not Gemini** → ask the user exactly one yes/no question, in their language: "혹시 Gemini를 유료로 구독하고 계신가요? (Y/N)". Then record the answer with `window.__EMQ_AI__.setFigureHelper({ available, command })` — `available: true, command: 'agy'` for yes, `available: false` for no. Recording a "no" matters as much as a "yes": it is what stops you ever asking again. The full walkthrough (install, sign-in) and how to hand a figure over are in §4, "Figures". Verify a helper cheaply (`agy --version`); do not spend a real `agy -p "…"` generation just to prove it works. - **`asked` is true** → the answer is already stored for this account. Use it; never re-ask. Why here and not §4: the Gemini decision (and any install) is the slowest, most human part of setup. Asking it at connect time overlaps it with the upload, instead of stalling once the review screen is already open. If you are Gemini yourself, the orchestrator's role shrinks to a few messages and a check — so there is nothing to set up, and you just work. ### Sign-in is the user's move — stop and wait There are two sign-in screens you may meet, and both belong to the user: - the app's own login page, and - an access gate at `easymakequiz.cloudflareaccess.com` that can sit in front of the whole site. Being bounced there is **normal, not an error** — and navigating back to the app just bounces you there again. When either appears, or `connect()` answers `NOT_LOGGED_IN`: 1. Tell the user, in one sentence, to sign in **in that window** and to tell you when they are done. (Korean users: "이 창에서 로그인해 주세요. 끝나면 알려주세요.") 2. **Stop. Wait for the user to say it is done.** Do not retry on a timer, do not reload in a loop, do not navigate away and back — a login screen does not change because you poll it, and every silent retry looks to the user like you are working when you are stuck. 3. When they say done, call `connect()` once and continue. Never type into a sign-in form and never ask for credentials — you have no business with their password. The session lands in the browser profile and persists for next time. Check yourself any time: window.__EMQ_AI__.status() // → { connected, watching, human, lastSeenSecondsAgo, bridge, lane } `human: false` means that window has no signed-in user. Uploading and reviewing will fail with 401 no matter what you do — hand it to the user and wait ("Sign-in is the user's move", above), do not retry. `window.__EMQ_AI__.help` is a one-paragraph reminder of all of the above, in the page. ### Heartbeat — say you are still there window.__EMQ_AI__.ping() **The connection's lifetime is tied to your liveness, not to the browser staying open.** Every call you make through `__EMQAI__` already counts as a heartbeat; `ping()` is for the quiet stretches between tasks. Two lengths of silence matter: | Silence | What happens | Still connected? | |---|---|---| | 60 minutes | the badge turns to "연결 확인 필요" (connection needs checking) and the user comes to look at you | yes | | 3 hours | the session ends. Every later call answers 401, and recognition goes back to costing the user credits | **no** | **While connected and idle, ping about every 30 minutes.** That keeps you out of both bands. If you do hit 401, you are not stuck and you do not need the user: call `window.__EMQ_AI__.connect()` again (§3, same answers). Only `NOT_LOGGED_IN` needs a human — that one means the user signed out of that window. Why it expires at all: a connection that outlives you keeps writing to that person's account after they have logged out or closed their browser. Tying its life to your heartbeat is what makes "connected" mean you are actually there — the same reason there is no connect button. ### After `connect()`: stand watch. Do not hand the turn back. `{ok:true}` is not the end of your job — it is the start of the waiting half of it. The failure mode is specific, and it has been measured (2026-08-27): the agent connected, said *"PDF를 올리고 검수 화면을 열어주시면 자동으로 인식하겠습니다"*, and **ended its turn.** The user did exactly that, pressed 「이 페이지 찾기」 — and nobody was listening. An agent runtime does not wake up because a web page changed. Their counter sat at 0, and at 60 s the screen offered them the paid lane. So do not end your turn on a promise. Two things are true at once and the answer sits between them: a background worker cannot do this (it has no vision model in it — §4), and neither can "I will check back later" (there is no later; your turn is over). **Use a doorbell**: a foreground script that blocks, watches for an order, and *exits* the moment one arrives, printing it. Your tool call returns, you are still inside your own turn with your own eyes, and the looking happens where the model is. // doorbell.mjs — run it in the FOREGROUND and let it block. Node 22+, nothing installed. // It fetches and it waits. It decides nothing; deciding is yours. import { readFileSync } from 'node:fs'; const dir = `${process.env.USERPROFILE ?? process.env.HOME}/.emq/chrome-profile`; const port = readFileSync(`${dir}/DevToolsActivePort`, 'utf8').split('\n')[0].trim(); // ...open the socket and define evaluate() exactly as in §2... const WATCH_MS = 5 * 60_000; // keep this UNDER your runtime's command timeout const deadline = Date.now() + WATCH_MS; let lastPing = Date.now(); while (Date.now() < deadline) { const seen = await evaluate(`(() => { const w = window.__EMQAI__; if (!w) return { surface: false }; return { surface: true, find: w.getFindRequest?.() ?? null, clean: w.getCleansingRequest?.() ?? null }; })()`); if (seen.find || seen.clean) { console.log(JSON.stringify(seen)); process.exit(0); } if (Date.now() - lastPing > 30 * 60_000) { // heartbeat, above await evaluate('window.__EMQ_AI__.ping()'); lastPing = Date.now(); } await new Promise((r) => setTimeout(r, 2000)); } console.log('{"idle":true}'); // nothing yet — say nothing, run it again process.exit(0); // ← required. See the note below. How to hold it: - **One line of output, at the end.** Not one per poll. The user reads your terminal too, and two hundred `waiting...` lines read as a malfunction. - **`WATCH_MS` must be shorter than your runtime's command timeout** (some are two minutes). If the script gets killed mid-wait, lower it — a doorbell that dies is worse than a short one. - **Exit explicitly, on both paths.** An open CDP socket keeps Node's event loop alive, so a script that merely runs off the end of the file prints its answer and then *hangs* — and your tool call never returns, which is the exact failure the doorbell exists to prevent. (Measured 2026-08-27: printed `{"idle":true}` after 5 polls, then sat there until killed.) - **An order printed is an order you take now.** Go to §4 flow 0 and work it to the end. Do not restart the doorbell while you are working: an order already in your hands does not need watching, and two loops on one order is the mistake §4 warns about. - **`idle` means run it again.** Ten quiet minutes are normal — the user may be at the scanner. Tell them *once* that you are on watch, then be quiet about it. - Stop only when the user says they are done for the day. Then say you are standing down, so they know the next 「찾기」 will cost credits. If your runtime cannot block on a command at all, say so plainly and ask the user to tell you when they have pressed the button. That is a worse experience — but a stated one beats a promise you cannot keep. ### Starting from a file the user hands you Usually the user uploads the PDF themselves and you wait (above). But when they hand *you* a file instead — "여기 이 파일 문제로 만들어줘", a path, an attachment you can read — you do not have to make them open the modal and pick it by hand. You already can read that file on their machine, so upload it for them: // dashboard tab, connected. Read the file yourself, then: await window.__EMQ_AI__.startRecognitionFromFiles([ { name: 'exam.pdf', mimeType: 'application/pdf', base64: '' }, ]) - **Why base64, not a file picker.** Driving a file `` with real file bytes needs `setInputFiles` (Playwright / CDP `DOM.setFileInputFiles`) — the raw-`Runtime.evaluate` path in §2 cannot do it, because page JS is sandboxed away from the local disk. Passing the bytes in as base64 is the one road that works from **either** runtime. You read the file; the page turns it back into a File and drops it into the same upload the human would have used. A `dataUrl` ("data:…;base64,…") is accepted too, and several files in one call are fine. - **Only PDF and images** (png/jpg/webp). Anything else comes back `{ok:false, reason:"BAD_FILE"}`. - **Answers:** `{ok:true}` → the review screen is opening; `window.__EMQAI__` appears in a moment — go to §4 and follow `flow`. The result's `next` spells out the auto recipe. `{ok:false, reason:"NOT_ON_DASHBOARD"}` → navigate to `/ko/dashboard` first, then call again. `"NOT_CONNECTED"` → `connect()` first (§3). `"BUSY"` → a review or save is still running; finish it first. - **This session is fully autonomous — do not wait for the user to click anything.** Because the user handed you the file and asked you to do it all, a session started this way lets you confirm the boxes yourself: detect every page with `setDetectedBoxes`, then call `window.__EMQAI__.confirmDetectedBoxes()` — that stands in for the human "confirm" click, and only works on a session you started this way (on a hand-uploaded session it is refused and the user confirms). `{ok:true}` opens `getConfirmedByoBoxes()`; recognize each crop and `submitProblem`. It starts a new worksheet; the review screen still shows so the user can watch, but nothing blocks on their input. (On a normal upload the user still confirms — see §4 flow step 5.) ## 4. Work — `window.__EMQAI__` ⚠️ Two names, one underscore apart. Both are real: - `window.__EMQ_AI__` — **connection**. Exists on every page. - `window.__EMQAI__` — **the work surface**. Exists only once the user has uploaded a PDF and opened the review overlay. Before that, `undefined` is normal — that is what the doorbell in §3 is for. Do not retry-storm, and do not end your turn to wait. Once it exists: const m = window.__EMQAI__.manifest() m.context // why you are here, in one paragraph m.flow // the ordered list of what to do m.methods // every method, with one line each on when and why **Read `manifest()` first, every time you reconnect or lose context.** It is written to be the only thing you need to recover from a compacted conversation. The short version of `flow`: 0. **Orders first.** `getFindRequest()` and `getCleansingRequest()`. If either is non-null, that is your job right now — the user pressed a button and is watching. If you are late, it falls back to credits and they pay. The same `id` is the same order: `pages` lists what **remains**, and it shrinks as your submissions land. A shorter list on re-poll is progress, not a new request — never start over, never re-detect a page you already submitted. And poll quietly: log one line when you take the order and one as each page lands, not on every check — the user watches your terminal too, and thirty identical poll lines read as a malfunction. Poll single-flight: never begin a new pass while one is still running, and never let two loops drive the same order. If you are about to fetch a page image you already fetched, submit that page first or say out loud why you cannot — a repeat read means the previous one went nowhere, and the screen now shows it as stalled rather than working. Spend as few of your own turns as possible per order: fetch the image, recognize, and submit as one continuous run — not one tool call per turn with thinking in between. Agent runtimes stall mid-thought, sometimes for minutes, sometimes until the user speaks; every stall between your steps is time the user watches a frozen counter (a real user waited 1,000 seconds this way). A watcher script that only *observes* the order while you think is the worst shape: the screen sees polling, the user sees nothing arriving. ⚠️ **A background script cannot do the looking — you can.** Measured 2026-08-22: an agent spawned a standalone `worker.js` to "monitor requests and submit recognitions", and it polled forever. Our side had served everything — the order was live, the page was empty, `getPageImage` returned a 724 KB image — but a plain Node process has no vision model in it. It could fetch the picture and it could submit; it could not *see*. The intelligence is in your conversation loop, not in a file you wrote. So split the work by who can do it: - A script may **fetch and submit** — plumbing, no judgement: `getPageImage`, `setDetectedBoxes`, `getCropImage`, `submitProblem`, `ping`. - **You** look at every image and decide what is on it. Read the picture into your own context and answer from there. If you find yourself writing "the worker will analyse the page", stop: that sentence has no subject. Nothing in the worker can analyse anything. The order's `pages` are the **whole** job. "이 페이지 찾기" (find this page) arrives as a one-page order — never widen it, and when the list empties, stop and wait for the next order. Sweeping the whole document on a one-page order overwrites boxes the user placed by hand; it is the clearest way to get this job wrong. 1. **Figure helper — you should have settled this at connect (§3).** If you asked at connect time via `window.__EMQ_AI__.getFigureHelper()`, it is done: the same answer is readable here as `__EMQAI__.getFigureHelper()` (same store), so just use it and move on. Only if you somehow skipped it: `getFigureHelper()` — Gemini-family skips entirely; otherwise, if `asked` is false, ask once and record with `setFigureHelper`; if `asked` is true, do not ask again. See "Figures: hand them to Gemini if the user has it" below. 2. `getFigureSpec()` — the figure contract. Do not guess the shape schema; read it. **A real-world photo, illustration, or map is not drawn — insert it as an image and add leader lines or callouts on top.** Vector shapes are for diagrams the tool can actually draw; anything photographic goes in as an image (drop it on the canvas, paste it, or pick a file), which the editor auto-shrinks to print size before it counts against storage. 3. `listPages()` → `getPageImage(i)` — but only for an order's pages, or, when there is no order at all, for pages without boxes. **Fetch in batches**: `getPageImages([i, j, k])` hands you several page images at once, so you look at them in one turn instead of burning a turn per page — turn count is the real floor on this job, and how many pages fit your context is your call, not ours. We cut only at a transfer ceiling that keeps the user's tab responsive, and whatever we could not send — a missing page, or one past that ceiling — comes back in `skipped` for you to ask for again, never dropped silently; batching the *reading* does not change rule 3 below, which is about *submitting*. **The detection method is not your choice.** Call `getDetectSpec()` and run its prompt, verbatim, against your own vision model with the page image. Do not build a local detector or a layout heuristic — that is how column-height boxes happen. It is the same prompt the paid lane runs, so a box you draw with it is a box the app already agrees with. Convert its `box_2d` output to percent exactly as the spec's footer says. 4. `setDetectedBoxes(i, boxes)` — **one call per page, the moment that page is done.** Not one call at the end with everything in it. **One box = one problem, and tight**: top edge just above the problem number, bottom edge after that problem's last line (its answer choices). A box the height of the column or the page, or one containing two problem numbers, is wrong — even when you ran the `getDetectSpec()` prompt as told, hold one result against this contract before your first submit. If the call comes back with a `warnings` array, your boxes probably broke the contract: that page is already in (it will not accept an overwrite), so fix it from the next page on and tell the user. 5. Confirm. On a session **you** started via `startRecognitionFromFiles` (the user handed you a file and asked you to do it all), do not wait for a human — after you have submitted every page's boxes, call `confirmDetectedBoxes()` yourself. `{ok:true}` means confirmed; go to 6. On a hand-uploaded session that call is refused, and you instead wait until `getConfirmedByoBoxes()` is non-null (the user confirms). Either way, 6 opens only after confirm. 6. `getCropImage(order)` → recognize → `submitProblem(...)` — **one call per problem, as each one finishes.** The user is watching a counter that only your calls can move. Problems that contain a figure go to the helper when there is one; text-only problems stay with you. 7. `getSubmitState()` — fix and resubmit anything rejected. ### Figures: hand them to Gemini if the user has it The owner of this app spent a year and $100+ measuring roughly 200 vision models against real exam figures, most recently two weeks ago. The result is not close: **Gemini reads mathematical figures more accurately than anything else tested.** That measurement is why this BYO lane exists at all. What Gemini does *not* have is the harness — agent runtimes like Claude Code and Codex finish a multi-step flow far more reliably. So use both, in the shape that plays to each: **you stay the orchestrator, and only the figure goes out.** You hold the order, the page loop, the submissions and the counter; a single crop at a time is subcontracted. **If you are Gemini, do not ask.** Antigravity CLI (`agy`), Gemini CLI, Gemini inside any wrapper — you already are the best figure reader there is, so a helper adds nothing but a round trip. Skip the question, record nothing (leave it for whichever agent connects next), and read the figures yourself. Only a non-Gemini orchestrator asks — Claude Code, Codex, Cursor, Cline, Copilot, and the rest. You know which one you are; nothing in the app checks. **Everyone else: ask once, then never again.** const helper = window.__EMQAI__.getFigureHelper() // {asked, available, command} - `asked: false` — ask the user exactly one question, in their language (Korean here): "혹시 Gemini를 구독하고 계신가요? 구독 중이시라면 병렬 연결을 추천드립니다. 더 빠르고 정확해질 수 있어요. 원하시면 안내해드릴게요." **If they subscribe and want the setup, do not stop at the offer — walk them through it.** Install the Antigravity CLI (`agy`) from its official page, https://antigravity.google/docs/cli/getting-started, following what is published there **today** (§0's rule: never type install commands from memory — they go stale, and so does your training data). Then the first run, then sign in with their Google account — sign-in screens are theirs to fill (§3, "Sign-in is the user's move"). Guide it the way §0 says to: one step per message, wait for the reply, name the exact button. When a command actually works, record that command: `setFigureHelper({ available: true, command: 'agy' })`. If they say no, or do not want the setup: `setFigureHelper({ available: false })`. - `asked: true` — the answer is already stored, in this browser, for this account. Use it. **`available: false` is a recorded "no", not an unknown.** Re-asking a user who already said no is the single thing they told us they hate most; the whole point of this pair of methods is that it happens once, ever. - If the user does not answer, record nothing and just do the figures yourself. Never block the job on this question — they are watching a counter. **The call.** No app code is involved: their CLI reads the PNG with its own file-view tool, so a path is all it needs. Save the crop to a file, then run the helper in print mode with the figure spec and that path: agy -p " Read the image at C:\Users\me\.emq\crop-12.png and return ONLY the figure JSON." `gemini -p "…"` for Gemini CLI; use whatever `helper.command` says when it is set. Do not try to inline the image as base64 — hand over the path and let the helper open it. Feed the JSON straight back in, unchanged: await window.__EMQAI__.submitProblem({ order, text, figure }) **When it fails, move on.** If the helper errors, times out, or returns something that is not usable figure JSON, recognize that figure yourself and submit. Do not retry in a loop and do not stall — a delegated call that never comes back looks exactly like a dead agent from the user's side of the screen. **Text-only problems never go out.** A problem with no figure has nothing for the helper to be better at, and the round trip is pure added latency. Two lessons paid for in real incidents (2026-08-19): - **Never route submission bodies through shell escaping.** Math text is full of backslashes (`\frac`, `\sin`), and heredocs/quoting silently halve them — a real agent lost a whole batch because `\\` became `\` and the JSON broke. Write the raw text to a file and let code read it and `JSON.stringify` it; never hand-build the JSON in a shell. - **If the bridge disappears mid-submission (`bridge: false`), keep your recognition results.** The review screen can close under you (user navigation, tab crash) — the work you already did is not lost unless you throw it away. When the screen reopens, `__EMQAI__` comes back: check `getSubmitState()` for what already landed, wait for the user to re-confirm, then submit the remaining results **without re-recognizing** — mapped to the new orders. Recognition is done; redoing it just makes the user watch a frozen counter again. Rules that are enforced, not suggested: - **All coordinates are percentages of the image you were given (0–100), never pixels.** A payload with pixel coordinates is rejected whole, with a reason. Partial acceptance would be worse: a wrong mask erases print, and erased print does not come back. - **Submit each page the moment it is finished. Never batch.** That rule is about *submitting*; fetching is the opposite — pull as many images at once as your context takes with `getPageImages` and decide them in one turn, then call `setDetectedBoxes` once per page. Take the whole order, by all means — but call `setDetectedBoxes(i, …)` as each page comes out, not once at the end with ten pages in it. A worker thinking silently and a worker that has died look identical from the outside, and the screen has no way to tell them apart. After 60 seconds with nothing arriving, the UI concludes you are not coming and offers the user the paid fallback — which costs them exactly the money you were there to save. This is the most common way a well-behaved agent still produces a bad experience: it is the default habit, and it is wrong here. The same applies to `submitCleansingMask` — answer each ask as it is ready — and to `submitProblem`: call it the instant one problem is recognized, not once at the end with twenty-six in hand. From the moment the user confirms, they are watching a `0/26` counter. Every problem you hold back is a minute that counter does not move. - **A function curve is one `functionGraph`, never a pile of short lines.** Approximating a curve with line segments produces something that only looks right: it has no expression, it cannot be selected as one object, and dragging a single point leaves a gap in it. Read `getFigureSpec()` for the field list. If you genuinely cannot read the expression off the page, send `line` — that is the only case where segments are the right answer. - **Choices follow the source layout.** If the source shows the five choices on one horizontal line, submit them as ONE line of text (`① 3 ② 4 ③ 5 ④ 6 ⑤ 7`); splitting them into separate lines saves them as a vertical list. If the source really does stack them one per line, send them one per line. The exam paper decides this, not your formatting preference. - **Math in problem text must be Easy LaTeX** — read https://easymakequiz.com/llms-easylatex.txt before your first `submitProblem` (one fetch, it is short). Formulas go between `$…$` and are written `frac(1,2)`, `sqrt(x)`, `sum_(k=1)^(n)`, `alpha` — **not** `\frac{1}{2}`, `\sqrt{x}`, `\sum_{k=1}^{n}`, `\alpha`. Korean sentences and the choice markers stay outside the dollars. Raw LaTeX is accepted by the renderer but degrades the stored problem, and one backslash silently turns Easy syntax off for the rest of that formula — the wrong text prints with no error anywhere. - **Do not move the user's screen.** Every method takes a `pageIndex`. If you genuinely need them to look at something, call `requestNavigate(i)` and wait for permission. - **Handwriting removal asks about the same page twice on purpose.** Two independent answers are intersected pixel-wise so that a single mistaken guess cannot erase printed content. Copying your first answer into the second defeats that protection entirely. Look again. - Everything you submit is saved automatically, in batches, a couple of seconds after it arrives. There is no save button for the user to press any more — a closed tab used to throw away everything they had waited for. This does not give you a free retry: a problem the server rejects for bad content is not resent on its own, so read `getSubmitState()` and fix what it lists. ## 5. Endpoints you may touch directly | Method | Path | Purpose | |---|---|---| | GET | `/api/ai-connect/agent-session` | `{agent, human}` — both booleans, nothing else | | POST | `/api/ai-connect/attach` | what `__EMQ_AI__.connect()` calls (cookies + CSRF header) | | DELETE | `/api/ai-connect/attach` | disconnect | | POST | `/api/ai-connect/ping` | what `__EMQ_AI__.ping()` calls. Prefer the page call — it is throttled for you | Everything else under `/api/` is for the app itself. Drive the page, not the API. ## 6. Pages - `/ko/connect` — the waiting screen, and the text a user hands to their AI - `/ko/dashboard` — upload a PDF, review, recognize. This is where the work happens - `/ko/problems` — the user's library - `/pricing` — plans - `/llms-easylatex.txt` — the math syntax for problem text (Easy LaTeX). Read before writing or editing any formula, and before answering "how do I write X in the editor" Korean is `/ko`, English is `/en`, Japanese is `/ja`. ## 7. Being a good guest - Recognition happening on your side costs the user nothing. Recognition falling back to our servers costs them credits. Speed is not a nicety here; it is their money. - Never touch a page the user did not ask about. `getFindRequest()` only ever lists pages with no boxes yet, precisely so that you cannot overwrite work they did by hand. - Do only the task. When `getSubmitState()` shows everything accepted, you are finished — stop there. Do not open the saved problems, do not navigate to `/ko/problems` or the editor to "check it went in right." The user reviews their own library; a worker that wanders off to self-verify is the most common piece of unwanted busywork the person has to watch. - Say what you are doing. The user is sitting in front of that window watching you. - Deliver as you go, not at the end. Ten pages arriving one by one and ten pages arriving together take you the same effort, but only one of them lets the person watching know you are still there.