Akshith Garapati

Using ChatGPT Web as an Agent Runtime

Coding agents are mostly a loop: a model decides what to do, something on your machine does it, and the result goes back to the model. Every one I've used pays for that model by the token. I already pay for ChatGPT, and its web app is a very capable model sitting in a browser tab, so I wanted to know whether that tab could be the runtime for the loop instead.

Parallax is what came out of that: an Electron desktop app, a Manifest V3 Chrome extension, and a system prompt that does a lot of work, with no API key anywhere in it. These are notes on how each piece works, and on the parts of a browser that fight back.

The shape of it

The desktop app is a workspace where every thread belongs to a folder. Its main process owns a WebSocket server on localhost:8765 and everything that touches the filesystem; the renderer is a Next.js app that draws threads and drives the loop. The extension has two halves that never share a JavaScript context: a service worker holding the socket and the map of threads to tabs, and, in each ChatGPT tab, a content script plus a page script that runs in the page's own world.

Each thread gets its own inactive background tab, and the service worker keeps the thread-to-tab map in extension storage, and merges it rather than replaces it when the worker restarts, so a terminated worker doesn't lose track of which tab belongs to which conversation. Tabs live inside a ChatGPT Project named plx-<folder>, created the first time a folder sends anything, with project-only memory selected so one repository's work doesn't leak into another's.

The content script is injected into every chatgpt.com tab, but it starts inert. The worker sends standby to tabs it doesn't own and resume to the ones it does, so a tab you opened yourself is never typed into. The page script's network hooks are armed only while a Parallax turn is in flight, for the same reason.

One turn→ out
1/7
Desktop app
Electron
Extension
service worker
Task tab
content + page script
chatgpt.com
the model
Compose the wire message

Your text is wrapped in {plx:task}, and on a thread's first message the system prompt and a Workspace block are appended, while the thread still shows only what you typed.

Fig. 1The route one turn takes. Step through it, or let it play.

The wire message

The desktop stores your text for display and composes a longer wire message for ChatGPT: the task first, then the prompt, then the workspace block. Only the first user message of a thread carries the prompt; after that, the wire text is just your message.

apps/desktop/lib/agent/systemPrompt.ts
export function composeWireMessage(userText: string, folderPath?: string | null): string {
  return `{plx:task}
${userText.trim()}
{/plx:task}

${SYSTEM_PROMPT}

${workspaceSection(folderPath)}`
}

The task goes first because with the prompt first, the model treats the whole message as configuration and often replies by summarising its instructions. The prompt itself is an output contract: every reply is either an ACT (one {plx:note} and one to four action tags, then stop) or an ANSWER (a single {plx:done} under 600 words), never both, and never prose outside the tags. The note gives the thread a line of plain commentary before each batch of actions.

The rule about tools took the most iterations to get right. Asked to look through a repository, the model would open its own code interpreter, list an empty sandbox, and report the project was empty. Insisting the Parallax tools were real did nothing, because it did have real tools; they were just the wrong ones. Naming the built-in ones and forbidding them stopped it:

apps/desktop/lib/agent/systemPrompt.ts
Do not use native tools, a code interpreter, or another terminal. They
cannot access the mounted workspace. Do not claim you lack access or ask
for uploads; emit Parallax actions.

The prompt also draws a line the parser doesn't: it advertises only run and write, and tells the model the shell is the toolset, with ls, sed, rg and git named directly. A dedicated read tag existed first, and the model kept reaching for cat anyway. It ends with one worked example of a complete loop, including a fabricated result block, because models refuse far less once they have seen the cycle resolve.

A grammar that survives a web page

The delimiters took three attempts before one of them survived a round trip. Angle brackets came first, and ChatGPT's renderer parsed paired tags as HTML elements, so they came back stripped of their brackets while self-closing tags survived, which made the failure look intermittent. Guillemets (‹ ›) rendered fine but the model reproduced them unreliably, especially mid-stream. Braces won: nothing treats them as markup, and the model types them consistently.

apps/desktop/lib/agent/protocol.ts
const TAG_RE = /\{\s*plx:(note|read|list|search|run|write|done)\b([^}]*?)(?:\/\}|\}([\s\S]*?)\{\s*\/\s*plx:\1\s*\})/g

Group 1 is the tag, group 2 the raw attributes, group 3 the body of the paired form. The backreference pairs a close tag to its own opener, so a write body containing another tag doesn't terminate early. The close form tolerates stray whitespace, because the model sometimes writes { / plx:run }. Attributes are only recognised as double-quoted pairs, and HTML entities are decoded first, since the page hands back &quot; where the model typed a quote.

The parser has two more jobs beyond finding the tags. The first is display, since the same text is rendered in the thread, so a second pass unwraps done, drops result blocks and action tags, and cuts an unfinished action and everything after it. While a reply streams, that means tags never flash on screen before their closing brace arrives. It deliberately leaves a bare trailing { alone, so code in an answer isn't mangled by the cleanup.

The second job is repair, for the case where the model invents its own delimiter. If a reply looks like protocol but yields no complete action, the desktop sends a silent correction asking the model to re-emit it, at most twice per thread. The detector is loose on purpose, because the failure it catches is the model inventing a delimiter:

apps/desktop/hooks/parallax/useAgentRuntime.ts
/(?:^|[<{‹⟨«〈<〈])\s*\/?\s*plx\s*:/im
A model replyedit it
What the parser found4 tags
  1. noteChecking the layout and the test script before running anything.shown in the thread
  2. runls -laread-only
  3. runsed -n '1,40p' package.jsonread-only
  4. runpnpm testchanges things
Fig. 2The parser the loop uses, simplified. Edit the reply and watch what the desktop makes of it.

Running the actions

Actions execute in the main process, over IPC, with progress streamed back to the renderer as it arrives. read, list, search and write all resolve their path against the workspace root and refuse anything that escapes it:

apps/desktop/lib/agent/executor.ts
function resolveInside(cwd: string, p?: string | null) {
  const target = path.resolve(cwd, p || '.');
  const rel = path.relative(cwd, target);
  if (rel === '') return target;
  if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
  return target;
}

run has no such guard and is a plain exec in the workspace folder with no path restriction, because a shell command can cd anywhere regardless of what the executor checks. That is exactly why run sits behind the permission gate, and why the prompt tells the model the workspace is a write boundary, not a read boundary.

Everything else is caps on how much work one action can do. Reads stop at 200 KB and leave a truncation marker, and search walks at most 5,000 files, skipping node_modules, .git, dist and friends, skipping files over 1 MB, and bailing out of anything with a NUL byte in the first chunk, which filters out binaries. Commands get 120 seconds and 100 KB of captured output, formatted as $ cmd, the output, then [exit 0], so the exit code is in the text the model reads.

Read-only actions in the same reply run in parallel; anything else runs in order. What counts as read-only is decided by an allowlist of binaries plus a rejection of every shell metacharacter, so sed -i, find -exec and anything with a pipe or a redirect fall out of the fast path automatically.

LimitValue
Command timeout120 s
Command output kept100 KB
File read200 KB, then truncated
Search100 matches, 5,000 files, files over 1 MB skipped
One result sent to the model6,000 characters
All results in one turn24,000 characters
Rounds before the loop must answer30

The permission gate

There are three levels, and they differ only in when the desktop stops to ask. Manual asks before every command and every write, auto-review, the default, asks only when the model marked an action approval="required", and full access asks for nothing. When one action is gated, the rest of the reply is affected too:

apps/desktop/hooks/parallax/agentTurn.ts
const firstApprovalAction =
  level === 'approve'
    ? parsedToolActions.find(requiresManualApproval)
    : level === 'auto-review'
      ? parsedToolActions.find(requestsReview)
      : undefined
const toolActions = firstApprovalAction ? [firstApprovalAction] : parsedToolActions

The batch collapses to the single gated action, everything else in that reply is dropped, and the model finds out by getting fewer results than it asked for. Running the three safe actions first would mean that by the time the approval dialog appears, the state the model reasoned about has already changed.

A denial also produces a result, with status denied and the text "Do not retry this action; find another approach or ask", which is enough to stop the model from trying the same command three more times.

Permission levelwaiting on you

the default: asks only for actions marked approval="required"

  1. noteChecking the layout and the test script before running anything.shown
  2. runls -ladropped
  3. runsed -n '1,40p' package.jsondropped
  4. runpnpm test
Sent back as the next messageoutputs are stand-ins
(waiting for your answer above)
Fig. 3The same reply under each permission level. Command output is a stand-in.

Fitting results back into a chat box

Results return as {plx:result} blocks in a silent message: it goes to ChatGPT but never appears in your thread. The limits on that message come from the transport rather than from taste. ChatGPT rejects messages beyond a certain size, and a rejected continuation strands the loop mid-task, so the turn has a hard ceiling of 24,000 characters and each result a cap of 6,000. The budget is shared out by size:

apps/desktop/lib/agent/protocol.ts
// Max-min fair ("water-filling") split of `total` across `lengths`: every
// result that fits inside an equal share keeps ALL of its content, and the
// space they leave unused is redistributed to the big ones.
function allocateBudgets(lengths: number[], total: number): number[] {
  const out = Array.from<number>({ length: lengths.length }).fill(0)
  const order = lengths.map((len, i) => ({ len, i })).sort((a, b) => a.len - b.len)
  let remaining = total
  let left = order.length
  for (const { len, i } of order) {
    const give = Math.min(len, Math.floor(remaining / left))
    out[i] = give
    remaining -= give
    left -= 1
  }
  return out
}

With fifteen short results and one enormous file, only the file is truncated, instead of all sixteen being cut to a sixteenth each. When something is cut, the head and tail survive and the middle goes, on line boundaries, because a file read is most useful from the top and a failing command from the bottom. The marker states the real size and suggests sed -n '120,240p', so the model asks for the slice it wants instead of guessing.

One more block gets appended: a short guidance result telling the model to answer now if what it has is enough. Without it, models happily spend another four rounds confirming things they already know.

Getting text into the composer

ChatGPT's composer is a ProseMirror editor, and the direct approaches fail. Setting textContent leaves React's view of the document unchanged, and a synthetic paste works right up until the text is long, at which point ChatGPT converts it into a "pasted text" attachment, and the model receives a file instead of a prompt. The page script therefore edits the editor directly:

apps/extension/src/inject.ts
const view = findEditorView(node);        // walks up React's fiber tree
const state: any = view.state;
const paragraph = state?.schema?.nodes?.paragraph;
// One paragraph per line: insertText would drop the whole string into a
// single text node and the message would render as one run-on line.
const paragraphs = String(text).split('\n').map((line) => (
  paragraph.create(null, line ? state.schema.text(line) : null)
));
const tr = state.tr.replaceWith(0, state.doc.content.size, paragraphs);
view.dispatch(tr.scrollIntoView());

The editor view isn't exposed, so it's found through the DOM node's pmViewDesc or by climbing up to thirty levels of React fibers looking for something with a dispatch. If an attachment chip appears anyway, the content script clicks ChatGPT's own remove button and retries the text in 1,200-character pieces with a 30 ms gap.

Verifying that the text landed needed a fix of its own. ProseMirror reparses the insert into one paragraph per line moments later, so innerText gains a blank line between every line, and an exact comparison reports a message as lost while it is plainly on screen. That bug produced a banner insisting the text wasn't there, over a screenshot of the text being there. The check is now whitespace-insensitive:

apps/extension/src/content.ts
function composerRetains(input: ScrapedElement | null, text: string): boolean {
  return compactMessageText(composerText(input)) === compactMessageText(text);
}

The last obstacle is the remount that a Project causes. A conversation inside a Project redirects from /c/<id> to /g/g-p-…/c/<id>, which tears down the editor and silently discards whatever was in it. The send loop treats a composer that has gone empty while waiting for an enabled send button as a signal to start the insertion over, and gives up after two attempts. A send is only considered accepted when the page script reports a POST to the conversation endpoint within eight seconds of the click; a click that produced no request raises an error naming that specific failure.

Reading the answer back

The first version scraped the finished answer out of the DOM. It was truncated, already rendered to HTML, and sometimes contained a reasoning model's "Thinking" block instead of the answer. The page script now wraps fetch, EventSource and WebSocket, because ChatGPT has used all three. The fetch hook is deliberately indiscriminate:

apps/extension/src/inject.ts
// Tee EVERY event-stream, whatever its URL. ChatGPT now hands the answer
// off to a SEPARATE "resume_sse_endpoint": the /f/conversation POST just
// returns a stream_handoff ticket and closes. Matching the answer by URL
// is therefore wrong by construction.
const ct = resp.headers.get('content-type') || '';
if (resp.body && ct.includes('text/event-stream')) {
  parseStream(resp.clone(), sink);
}

Because the clone is a tee, it has to be drained to the end even after the answer is complete. Stopping early applies back-pressure to the page's own copy of the stream, and ChatGPT's UI stalls mid-render. The events themselves are patch operations against the message object:

apps/extension/src/inject.ts
if (o === 'append' && isString(v) && pth && partsRe.test(pth)) {
  // An append targeting a content-parts path IS answer text. Accept it
  // unless we positively know the current message is reasoning/tool output.
  if (capturing !== false) {
    capturing = true;
    text += v;
    lastPath = pth;
    publishText();
  }
  return;
}

The capturing flag starts as null rather than false: unknown, not excluded. Message headers sometimes arrive after the first delta, and a strict default drops the opening sentence of every answer. It flips to false on any message whose content type is thoughts, reasoning_recap, code, execution_output or a handful of others, which is how reasoning output is excluded without looking at the page at all.

Detecting the end of a turn took several attempts at reading the frames. message_stream_complete is not terminal; it marks a boundary between messages in the same turn. The real end is conversation-turn-complete, or the matching [DONE] for the handoff stream. Every text change posts the entire accumulated string rather than a diff. The desktop keeps a high-water mark and ignores any snapshot shorter than the last, so a dropped or reordered frame cannot truncate an answer.

The desktop refuses a final answer from anywhere but that capture, with one exception. If a tab reloads mid-turn, the content script polls the rendered page every 500 ms for up to three minutes, looking for a complete tagged reply after the prompt, and marks it as a page recovery.

Staying connected

Chrome terminates a Manifest V3 service worker when it goes idle, which drops the WebSocket and every timer it owned. A chrome.alarms tick every 30 seconds revives it and reconnects, and Electron pings each client every 10 seconds from the other side. The reconnect backoff is unusually short:

apps/extension/src/background.ts
// Cap low so a relaunch reconnects within one short beat; the 30s alarm
// remains the backstop for a terminated worker.
const delay = reconnectCount <= 3 ? 1000 : Math.min(2000 * (reconnectCount - 2), 5000);

The other end is on the same machine, so the usual reasons for exponential backoff don't apply. What does happen is that the desktop app restarts and the extension sits out a long timer while the UI claims nothing is connected.

Turns survive interruptions in the browser as well as in the desktop app. The worker tracks active turns, persisted with a ten-minute expiry, and broadcasts them; when the desktop reconnects and finds its turn gone, it marks that message failed and the thread returns to an idle state. When a tab reconnects mid-turn, the worker asks it either to resume a turn that was never accepted, or to recover one that was. If two threads end up pointed at the same conversation, the worker reports a conflict and the desktop detaches the stale link and replays the message into a fresh conversation.

On top of that: failed continuations retry three times, a drifted tab is navigated back up to three times, a watchdog fails a turn that hasn't finished five minutes after it started, and messages you send while a turn is running are queued and drained one at a time. None of that machinery deals with the model; it deals with a browser that can reload, sleep or navigate mid-turn.

Testing something made of browsers

The extension is three scripts in three different contexts, and none of them can import each other. The build runs esbuild with bundle: false, transpiling each entry point one-to-one, because a bundled ES module can't be a content script. That has an awkward consequence for TypeScript: checked together, the files collide on every top-level const, since to the compiler they look like one scope. So each file is checked in its own generated tsconfig, with a shared ambient declaration file for the handful of globals they really do share.

The tests take the same route around the module system. They build the extension and evaluate the emitted dist/*.js inside a vm context with a fake window and a fake chrome, then drive it: push SSE frames through the network hook and assert what gets posted, or open and close sockets and assert the worker reconnects. That runs the tests against the emitted files, which is where a per-file transpile can differ from the source.

One invariant is enforced by the build instead of by review. Debug logging is compiled in from a frozen build-config.js that every script reads, and the committed value is false; a development build is the only thing that flips it. That gate exists because debug logging in this extension is expensive. An ambient DOM snapshot on every mutation once produced a 66 MB log in ten minutes, so snapshots are throttled to one per minute, mutation records are batched, and the desktop log file caps itself at 32 MB and records that cap on its last line.

The model picker

The picker shows what building on a product instead of an API costs. Reading /backend-api/models returns every internal model, including ones the account can't use, and none of the labels the UI shows. So Parallax scrapes ChatGPT's own menu instead: the composer trigger is found by data-testid, then by an aria-haspopup="menu" button containing a model-shaped label, and opened with a full pointer sequence, because Radix menus open on pointerdown and ignore a plain click().

Intelligence levels are only present in the DOM for whichever model is currently selected, so they are accumulated per model across openings and persisted. Some accounts render them as a slider instead of a list, so the scraper walks it once with arrow keys to learn the label at each step, then returns to where it started. Switching models means clicking, waiting, and re-reading the menu to confirm the change; a switch requested while a turn is sending is refused outright, because the model is chosen at POST time and changing it mid-send produces an answer from the wrong one.

When ChatGPT redesigned that menu, the picker broke the same week, and fixing it meant another afternoon of reading the DOM. The model costs nothing per token here; the integration costs a re-read of ChatGPT's markup every time it changes.