Running ChatGPT requests from IEx

You have a ChatGPT subscription. You also have an Elixir application that would benefit from asking a model a question. The obvious bridge between the two is the API, which is a separate product with separate billing. If what you want is the subscription you already pay for, the path runs through the browser that is already logged in.

This describes a working implementation: a Chrome extension holding a WebSocket to the Elixir server, a set of DOM commands the server can send it, and three modules that turn ChatGptTab.ask("james", "...") into an answer. Most of the interesting content is in the failure modes, because nearly every step has a version that appears to work and does not.

The bridge

The extension opens a WebSocket to the server and registers under a username. The server keeps those in a Registry, so DomCmd.query/5 looks up the handler for a user, pushes a JSON command, and blocks on a receive with a timeout.

The command vocabulary is small and deliberately not arbitrary JavaScript evaluation:

dom_query   dom_query_all   dom_click   dom_type
dom_wait    dom_watch       dom_unwatch
dom_extract dom_storage     dom_fetch

A content script injected into every page executes these against the DOM and sends results back. Reading pages this way is straightforward. Writing to them is not.

Typing is not assignment

The first instinct for entering text is element.value = text. On a React application this updates the DOM and changes nothing else.

React installs its own setter on the input prototype and keeps a record of the last value it wrote. Assigning through that setter updates the field but leaves React’s internal record untouched, so React compares the two, concludes nothing has changed, and never fires onChange. The application’s state keeps the old value. The visible symptom is a text box that clearly contains your text while the submit button remains disabled.

The fix is to bypass React’s override by calling the native setter lifted off the prototype descriptor, then dispatch an input event so React re-reads the field:

const proto = el instanceof HTMLTextAreaElement
  ? HTMLTextAreaElement.prototype
  : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;

setter.call(el, text);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));

ChatGPT’s composer is not an input at all. It is a contenteditable div, so there is no value to set, and the editor builds its model from InputEvents rather than from the DOM. The only call that emits the full, correctly typed event sequence those editors expect is document.execCommand("insertText"), which is deprecated and remains the thing that works:

const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);

document.execCommand("insertText", false, text);

Writing textContent directly and dispatching a synthetic InputEvent is available as a fallback and is genuinely worse. Some editors ignore it.

There is a good confirmation signal for whether the application accepted the text: ChatGPT’s send button does not exist in the DOM until the composer has content. Counting it before and after the write goes from zero to one. That is React state reacting, not just text appearing.

Submitting is not pressing Enter

Having typed the prompt, the natural next step is to dispatch a keydown for Enter. This reports success and sends nothing.

Events constructed in script carry isTrusted: false. Some applications do not care. ChatGPT does, or more precisely its composer submits through the send button’s own handler rather than through a keydown listener on the contenteditable, so a synthetic Enter has nothing listening for it.

Clicking the button works:

document.querySelector("button[data-testid='send-button']").click();

The dom_type command now tries Enter first, checks whether the field actually emptied, and falls back to the button. It reports which path worked rather than which path it attempted. That distinction matters more than it sounds, and the next section is why.

An emptied box does not mean a sent message

The submit path originally inferred success from the composer clearing. This is necessary but not sufficient, and it produced a false positive in practice: a run reported a successful button click while the tab sat at https://chatgpt.com/ with zero messages in it. Clicking send while the page is still initialising clears the box and drops the message.

For a chat application the authoritative signal is a user turn appearing in the conversation:

DomCmd.query(username, tab, "dom_wait",
  %{selector: "[data-message-author-role='user']", timeout: 8_000})

The wrapper now waits for that and resubmits once if it does not appear, since the usual cause is a race against page load rather than a wrong selector. Failing at this point turns a silent five minute wait into an immediate and accurate error.

A related bug made this harder to find than it should have been. The helper that counted elements returned 0 on any failure, including an unreachable content script. That made “the prompt never arrived” indistinguishable from “still thinking”. It returns -1 now. Collapsing an error into a plausible value is a good way to spend an hour debugging the wrong thing.

Knowing which tab you are talking to

Opening a tab is fire and forget, so the obvious follow up is to find it by URL:

DomCmd.find_tab(username, "chatgpt.com")

This returns the first substring match. With several ChatGPT tabs open it returns an arbitrary one, silently, and the failure mode is not a failed command but a successful command against the wrong conversation. During development this typed a prompt into the composer of an existing chat.

URL matching is also chasing a moving target: ChatGPT rewrites its address from / to /c/<uuid> as soon as a conversation exists.

The fix is to identify the tab by identity rather than by pattern. Snapshot the open tab ids, open the URL, then poll until an id appears that was not in the snapshot:

{:ok, tab} = DomCmd.open_new_tab(username, "https://chatgpt.com/")

This is correct regardless of how many sibling tabs are open and regardless of how the application rewrites its own URL afterwards. It has a second benefit: every prompt gets a clean conversation, with no prior context leaking in.

One wrinkle. open_new_tab/3 returns as soon as the tab id exists, which is before the page has loaded, so for the first second or so the tab has no content script and reports “Content script unreachable”. That is a normal transient state for a newly created tab, not an error, and the composer wait retries through it while returning any other error immediately. Retrying a real fault only delays the diagnosis.

Knowing when the answer is finished

Polling until the text stops changing is the tempting approach and it misfires on a model that pauses mid answer. ChatGPT renders a stop button while generating and removes it when done, which is a much cleaner edge:

DomCmd.query(username, tab, "dom_query_all",
  %{selector: "button[data-testid='stop-button']", type: "text"})

Zero means finished. The poll loop also refuses to return an empty string before it has ever seen generation start, on the grounds that a prompt which never registered should look like a timeout rather than like an empty answer.

Reading the answer without destroying it

Having waited for the answer, the obvious way to collect it is textContent. This returns the right words in the right order and quietly throws away the structure.

textContent concatenates text nodes with no regard for layout. Block elements do not introduce breaks, so a rendered Markdown answer arrives as a single unbroken line: headings fused to the paragraphs beneath them, list items run together. It measured zero newlines on a fifteen line article. Every word was present and the document was gone.

innerText is computed from the rendered box model, so block boundaries become newlines and the structure survives:

case "innerText":
  return (el.innerText || el.textContent || "").trim();

The same answer came back with fourteen newlines and its headings intact.

There is a second, smaller trap in the same place. The element carrying data-message-author-role="assistant" wraps both the answer and ChatGPT’s action toolbar, so reading the wrapper prefixes every answer with the word “Edit”. Targeting the .markdown body inside it avoids that, with a fallback to the wrapper if the inner class ever changes, on the grounds that a slightly dirty answer beats no answer.

The API

Short prompts:

{:ok, r} = ChatGptTab.ask("james", "evaluate arguments in <url>")
r.answer
r.elapsed_ms

That opens a fresh tab, waits for the composer, types, submits, confirms the prompt landed, waits for generation to finish, and returns the answer.

Long prompts need the split form. An article evaluation in testing took one minute forty one seconds, and another ran past three minutes, which is long enough that the calling process can time out while the browser carries on working perfectly well:

{:ok, tab} = ChatGptTab.send_prompt("james", "evaluate arguments in <url>")

# later
{:ok, answer} = ChatGptTab.await_answer("james", tab)

Progress is inspectable in between:

ChatGptTab.turns("james", tab)   # %{user: 1, assistant: 0} means still thinking

Storing the results

Prompts and answers go into a single llm_queries table shared by every model the system drives, rather than one table per provider. The questions worth asking of this data are comparative – what did each model say to the same prompt, which is slower, which fails more often – and per provider tables turn every one of those into a UNION while drifting apart as each gains a column the others lack.

Two columns are worth calling out. provider is which model answered; transport is how it was reached. ChatGPT through a browser tab and ChatGPT through its API are one provider and two transports, and they fail in entirely different ways. Keeping them separate means “is the model slow today” and “is the browser bridge flaky” stay separable questions.

The row is written before the prompt is sent, not after. A run that hangs or times out still leaves a record. Write on completion and the failures, which are the cases most worth reading later, are exactly the ones that leave no trace.

Recording lives in a wrapper rather than in each provider module:

LlmProvider.run(ChatGptTab, "james", "evaluate arguments in <url>")

A provider implements three callbacks – provider/0, transport/0, ask/3 – and gets recording for free. This makes a provider that forgets to record impossible, and it means the error branch cannot be quietly skipped. A provider that raises still closes its row.

The convenience of also letting ask/3 record when called directly turned out to have a cost. ChatGptTab.ask/3 wraps itself in run/4 so that an IEx user gets a row without knowing about the wrapper. When something else called run/4, which then called ask/3, which wrapped itself in run/4 again, one prompt produced two identical rows. The fix is for run/4 to mark its options as already recorded and for the provider to check that marker. Worth stating plainly because the shape recurs: a function that is convenient alone and a function that composes are not always the same function, and making one do both means giving it a way to tell which situation it is in.

Filing the answer as an article

The last step in the chain is doing something with the answer. Writing it to a Markdown file is one call:

{:ok, r} = LlmBlog.from_prompt("james", "Explain SQLite WAL mode")
r.url

That runs the prompt through any LlmProvider, waits, and writes the result to the blog directory with a timestamped filename derived from the answer’s own heading.

The interesting part is not the file write. It is that the blog has conventions which a model does not know about, and the boundary is the right place to apply them.

Frontmatter marking the post public is prepended unconditionally, because without it the public route returns 404 and an article that renders perfectly well locally is invisible to everyone else. Em and en dashes are rewritten, because they render as mojibake on the site and models produce them constantly. A heading is added only when the answer does not already open with one, since two titles render as two titles.

A failed prompt writes nothing at all and returns an error. Filing a stub article and letting the site serve it would turn a failure into published content. The database row still records the attempt, so a run that produced no article remains auditable rather than simply absent.

One detail that only shows up in testing: deriving a title from the answer means handling answers that have no usable title. The first implementation skipped lines that began a code fence but not the lines between fences, so an answer consisting only of a code block took its title from the code. Fence state has to be tracked rather than pattern matched, and when nothing usable is found the prompt itself is the fallback.

Reading back:

LlmQuery.recent(5)
LlmQuery.compare("evaluate arguments in <url>")   # same prompt, every provider
LlmQuery.stats()                                   # counts and timings by provider

What this is not

Every selector here is ChatGPT’s current DOM. #prompt-textarea, [data-message-author-role], data-testid="send-button" are not a contract and will break without notice. They are at least confined to one module, which is the most that can be said for them.

The approach also inherits everything about the browser session: if the extension is not connected, or the tab is asleep, or the account is logged out, there is no answer. The API does not have these problems, and where reliability matters more than reusing a subscription, the API is the right tool.

What the browser path does give you is access to exactly the product you are paying for, from inside your own application, with the full prompt and answer stored somewhere you can query. For occasional analysis work that is a reasonable trade.

The lesson that generalises

Most of the bugs described here share a shape. A synthetic Enter that dispatches without submitting. A value assignment that updates the DOM without updating the application. A cleared text box that looks like a sent message. A count that returns zero for an error.

Each is an operation reporting what it attempted rather than what it achieved. Browser automation is unusually rich in these because the DOM will let you do almost anything, and almost nothing tells you whether it worked. The fix in every case was the same: find an independent signal that the intended effect actually occurred, and report that instead. A send button appearing. A user turn in the conversation. A field that emptied. A negative number that cannot be mistaken for a real count.

The textContent bug is a variant worth separating out, because it did not report failure at all. It succeeded. It returned every word of the answer in the correct order, and the only thing missing was the structure holding them apart. Nothing in the return value indicated a problem; the value simply had less information in it than the caller assumed. That class is harder to defend against than an outright false positive, because there is no boolean to distrust. The only real protection is knowing what your extraction primitive actually promises, and textContent promises text, not documents.

The double-recorded row is a third variant again: nothing was wrong with either function, only with their composition. Both did precisely what they claimed. The defect existed solely in the arrangement.

All three are worth writing down together, because the reflex they teach is the same. When automating something you do not control, the question is never whether the call returned. It is what independent evidence you have that the thing you wanted actually happened, and whether that evidence would still hold if the call had quietly done half its job.

It is slower to write and it is the difference between automation you can trust and automation that fails silently at three in the morning.