appshotr-mcp
v0.2.4
Published
appshotr MCP — drives your logged-in ChatGPT in a real Chrome: attach an image, prompt, wait for the generated image, save it, next chat.
Downloads
1,183
Maintainers
Readme
appshotr-mcp
Drives your own ChatGPT — your login, your subscription — inside a real Chrome window, from Claude Code.
The loop it was built for:
fresh chat → attach a source screenshot → send the prompt → wait for the generated image → save it to disk → next chat, next prompt.
No OpenAI API key and no per-image API bill: it is your browser session, typing and clicking like you would.
Install
npm install -g appshotr-mcp
appshotr setupsetup does the three things in order and skips what is already done: registers the server in every
MCP client it finds on the machine — Claude Code, Codex CLI, Gemini CLI, Claude Desktop, Cursor,
Windsurf, VS Code — links this machine to your appshotr.com account (a URL and a short code you
approve in a browser you are already signed into), and opens a dedicated Chrome window for you to
sign in to ChatGPT once.
The registration is the client's own: claude mcp add, codex mcp add, gemini mcp add where the
client has a CLI; a merge into the JSON file it reads (claude_desktop_config.json, ~/.cursor/mcp.json,
~/.codeium/windsurf/mcp_config.json, VS Code's user mcp.json) where it does not — one key added,
nothing else touched, a .bak next to the file. appshotr install runs just that step
(--client codex cursor to pick, --list to see what was found, --remove to take it out again).
In Claude Code the flows are slash commands; in any other agent you just ask — "make the App Store
screenshots for this app with appshotr" — and the server's instructions walk the same stops.
Nothing runs without an active Companion plan: every tool and every command checks it with
appshotr's server first (a 15-minute access token, so one request per quarter hour), and stops the
moment the plan lapses or the machine is unlinked from Settings. Only setup, link, doctor and
install work unlicensed, because they are how you get licensed.
Two logins, on purpose: appshotr is the plan (it holds the style catalogues the prompts are built
from), ChatGPT is the session that draws. appshotr doctor shows the state of both. Each step is
also its own command — appshotr link, appshotr login — and appshotr unlink forgets the plan's
secret on this machine (the device slot stays until you revoke it in Settings). The secret is never
printed.
Then, in Claude Code, type / and pick:
/mcp__appshotr__screenshots an App Store set — it finds your screenshots
/mcp__appshotr__icon an app iconYou do not have to say where the screenshots are: Claude looks in the project, on the Desktop, in Downloads and Pictures for real device captures (Simulator, AirDrop, fastlane) and asks "these?". Both commands walk Claude through the whole job and show you the plan before anything is generated — the style, the headline per slot, which screenshot goes where — and wait for your yes. Claude reads your project first (README, store metadata, onboarding strings) so the headlines describe your app, not a generic one, and it never invents ratings, awards or numbers that are not in the product.
The set's memory: .appshotr/ in your repo
The first run writes it; every run after that starts from it, so "change headline 3", "I retook screen 2" or "also export 6.5" is one change, not a new set. It is meant to be committed, like fastlane's screenshots — a teammate inherits the plan.
.appshotr/
plan.json the brief and its evidence, the style, the size, and per image:
capture, headline, and which generated image it delivers
screenshots/ a copy of the captures used (Downloads is not a place to keep them)
generations/ every image ChatGPT ever made for this app — never moved or overwritten
screenshots/repost-2026-08-22-k7m2/ one set: style · day · a short id so two sets never collide
set.json what was generated, with which prompt, in which chat
01-throw-a-real-punch/ one image: prompt.txt, raw-1.png (+ raw-2.png when ChatGPT
01-throw-a-real-punch-2/ offered a pair); a regeneration gets its own folder
icons/iridescent-bolt-2026-08-23-f3q8/ one icon per folder, same idea
out/ exactly what you upload, rebuilt from the plan at any time
iphone-6.9/01-throw-a-real-punch.png
app-icon/icon-1024.pngplan.json is plain JSON with paths relative to the repo: edit a headline by hand if you like, Claude
reads it back. out/ is derived — delete it, or point a slot's source at another variant or an older
generation, and plan_export (or appshotr plan export, also --size iphone-6.5) rebuilds it with no
browser involved. generations/ and out/ are git-ignored by default (.appshotr/.gitignore, one line
to change); the plan and the captures are what a teammate needs.
From a clone instead:
npm install
npm run appshotr -- setup # same commands, prefixed with `npm run appshotr --`A dedicated Chrome profile opens (%LOCALAPPDATA%\appshotr-mcp\chrome-profile). Sign in there. The
session is remembered — it is a separate profile from your everyday Chrome and from other tools'.
Only one process can own that profile at a time. Stop the MCP server (or close its Chrome window) before running
appshotrcommands in a terminal, and vice versa.
Use it from Claude Code
Tools exposed by the appshotr MCP server:
| Tool | What it does |
| --- | --- |
| chatgpt_status | Chrome, profile, signed-in state, default output folder. Call first. |
| chatgpt_login | Opens the login page and waits for you to sign in. |
| generate_image | Fresh chat → attach input_images → prompt → wait for the image → save to out_dir. One call per image; saves 2 files when ChatGPT runs its A/B variant mode. |
| ask | Same machinery, text reply as markdown. |
| collect_images | Rescue path: download the images of a chat that already rendered (chat_url). |
| export_apple | Convert existing images to App Store Connect spec (exact size, RGB, no alpha). No browser. |
| chatgpt_probe | Diagnostic: which UI selector currently resolves for each role. |
Typical ask in Claude Code:
"Take every PNG in
./shots, and for each one ask ChatGPT for an App Store screenshot with the phone frame and a headline, saving the results in./out."
Claude calls generate_image once per file. Each call is its own chat, so nothing bleeds between
screenshots, and the tool blocks until the image is on disk (30–120 s each).
Use it from the terminal
npm run appshotr -- gen "pon esta pantalla en un mockup de iPhone 16 con fondo degradado" \
-i ./shots/home.png -o ./out -n home
npm run appshotr -- batch jobs.json -o ./out # [{ "prompt": "...", "images": ["..."], "name": "home" }, …]
npm run appshotr -- ask "resume esto en 5 bullets" -i ./shots/home.png
npm run appshotr -- collect https://chatgpt.com/c/<id> -o ./out
npm run appshotr -- export ./out/*.png --apple iphone-6.9 # existing files → App Store spec
npm run appshotr -- doctor --page # health + live selector probeApp Store output
ChatGPT never returns an App Store size on its own, so a raw generated PNG cannot be uploaded. Pass
apple_size (tool) or --apple (CLI) and you get a second file at an exact accepted size, RGB, no
alpha — iphone-6.9 (1260x2736) by default, plus 6.5", 6.3", 6.1" and iPad 13".
The export never invents and never distorts. What you upload is what ChatGPT drew:
- Before generating, the source is letterboxed onto a canvas with the slot's ratio and attached, and the prompt states the exact pixel size. ChatGPT takes its output shape from what you attach, so this pulls it to ~1:2.17 instead of its default 2:3.
- After downloading, in order:
- shape already Apple's (±0.5%) → pure LANCZOS scale, every pixel ChatGPT's. Reported
exact. - otherwise, measure the band that would have to come off the sides. If it is empty background —
per-row variation under 14/255, so a vertical or diagonal gradient still counts as empty —
trim it. Reported
cropped, with how many px went. - artwork reaching the edges → regenerate (up to
attempts, default 3) rather than ship a padded or squashed image.
- shape already Apple's (±0.5%) → pure LANCZOS scale, every pixel ChatGPT's. Reported
- Among the sizes Apple accepts for the slot, it exports to the one that needs no alteration if there is one, and otherwise to the smallest — the least upscaling from ChatGPT's ~900px width.
--fit pad (extend the background), --fit crop (always trim) and --fit stretch are there when
you want to override that, but auto is the default and it never fakes pixels.
When something hangs
APPSHOTR_TRACE=1 logs every poll of the wait loop, so a hang explains itself instead of needing a
guess:
[appshotr] trace 54s streaming=true turns=1 imgs=1/1 text=77 idle=- stable=2s imgStable=2sstreaming = ChatGPT's stop button is up. imgs=a/b = images found / fully decoded. idle = how
long the stream has been closed. imgStable = how long the image set has been unchanged. If it ever
waits with imgs=1/1 for more than imageStableMs, that is a bug — send that line.
When ChatGPT's UI changes
Everything the automation touches is one list of candidate selectors per role in
src/chatgpt/selectors.ts. Run appshotr doctor --page (or the
chatgpt_probe tool): it prints which candidate resolved for each role, and - none - for the one
that broke. Add the new selector at the top of that role's list — nothing else needs to change.
What ChatGPT's UI actually does (learned the hard way, live)
Each of these cost a debugging round; they are why the code looks the way it does.
- Attachments must be DROPPED, not set on the file input.
setInputFiles('#upload-files')leavesinput.files.length === 1and fires zero upload requests — ChatGPT ignores the synthetic change event, the prompt is sent without the image, and the model happily invents something unrelated.attach()dispatches a CDPInput.dispatchDragEventwith the file paths instead: the browser itself produces the drop, so the app sees a real one. The+menu is not a fallback — it does not open under a programmatic click. - The composer keeps an unsent draft across navigation, even into a brand-new chat. Without
clearComposer()the previous prompt prefixes the next one.waitForComposer()clears it. - Turns are
section[data-testid="conversation-turn-N"][data-turn="user"|"assistant"].data-message-author-roleexists only on the USER turn in the current build, so anchoring the reply on[data-message-author-role="assistant"]finds nothing at all. - While ChatGPT "thinks" there is no assistant turn in the DOM — an image request sits at "Pensando…" for a minute or more with only the user turn present. So the wait is two-phase: first confirm the prompt was accepted (user turn appeared / stop button showed up), then wait for the answer to land.
- A finished image does not mean a finished stream. ChatGPT keeps the stop button up after the
picture is done — 4 s in one traced run, and indefinitely when its A/B prompt ("¿Qué imagen te
gusta más?") is waiting for a click. Waiting on that button alone burns the entire timeout with
the result already on screen. An image set that has not changed for
imageStableMsis treated as finished no matter what the button says. - A reply with no image is not a reason to wait either. If the stream ends, a turn exists and no
image ever showed up, ChatGPT answered in text or asked something: fail after
noImageGraceMswith what it actually said, instead of sitting out the five-minute image timeout. - Progressive previews are invisible to the DOM query: while the picture visibly renders in
Chrome, no
<img>of usable size exists yet. So "I can see it, why are you waiting?" is expected for a few seconds — the element only appears when the final image lands. - The same image is rendered several times inside one turn (thumbnail, comparison, full view) — dedupe by src or you save three copies of one image.
- A/B mode: ChatGPT sometimes generates TWO variants and asks "¿Qué imagen te gusta más?"
(
[data-testid="image-paragen-multigen"]). Both are final renders, so both get saved (-1,-2). - Opening a saved chat renders the turns before the images finish loading, so
collect_imagespolls for decoded images instead of sleeping. - The output size is NOT under your control. appshotr's backend asks the API for
1024x2224(already Apple's 1:2.17) and always gets it, so a plain resize is enough. The web UI gives the model the choice and it wanders: measured,1024x1536(2:3),941x1672(16:9),923x1704(1:1.847), then851x1849and851x1848(1:2.173 and 1:2.1716 — a 0.01% miss) once the attached canvas carried the slot's shape AND the prompt named the exact pixel size. So the canvas trick works but is not deterministic: the retry loop is what closes the gap. - Resolution is lower too: ~850-940 px wide from the browser vs 1024 from the API, so the export upscales ~45% instead of ~23%. Slightly softer; still sharp enough at App Store viewing size.
- A killed process leaves Chrome holding the profile, and the next launch just hands off to it
and dies ("Target page, context or browser has been closed").
getContext()detects that, kills only the processes whose command line points at the appshotr profile, and retries once —appshotr killdoes it by hand.
Two things that will bite you if you forget them
- Browser-side code must be a string. Anything running inside the page lives in
src/chatgpt/pagefns.tsas a string snippet. A function passed topage.evaluate()is serialized to source, and tsx/esbuild'skeepNameswraps declarations in a__name(…)helper that does not exist in the page →ReferenceError: __name is not defined, only when running from source. A test intests/pagefns.test.tsfails the build if a function-formevaluatesneaks back in. - One browser, one prompt at a time. Two concurrent calls would type into the same composer.
The second one fails fast with
busy:.
Config (env)
| Variable | Default | |
| --- | --- | --- |
| APPSHOTR_HOME | %LOCALAPPDATA%\appshotr-mcp | data root: profile + saved images |
| APPSHOTR_CHROME | auto-detected | path to chrome.exe |
| APPSHOTR_CHATGPT_URL | https://chatgpt.com | |
| APPSHOTR_IMAGE_TIMEOUT_S | 300 | wait for a generated image |
| APPSHOTR_REPLY_TIMEOUT_S | 180 | wait for a text reply |
| APPSHOTR_UPLOAD_TIMEOUT_S | 120 | wait for an attachment to finish uploading |
| APPSHOTR_BROWSER_IDLE_MS | 600000 | close the idle Chrome (reopens on demand); 0 disables |
| APPSHOTR_MIN_IMAGE_WIDTH | 256 | ignore smaller <img> in a reply (avatars, icons) |
Layout
src/
cli.ts commands: setup, login, doctor, gen, batch, ask, collect, mcp serve
config.ts paths, timeouts, env overrides
browser/chrome.ts real Chrome, persistent profile, idle reaper, session cookie check
chatgpt/selectors.ts every DOM hook, one candidate list per role
chatgpt/pagefns.ts browser-side snippets (strings — see above)
chatgpt/page.ts attach → type → send → wait for the reply → download the images
chatgpt/session.ts one-at-a-time lock, status/login/generateImage/ask/collectImages
mcp/server.ts the MCP tools (stdio) + Claude Code registration