GimmeImage
Images from a text prompt in seconds, with history, editing and an MCP server for Claude.
GimmeImage
Image generator from a text prompt, plus editing of your own images. A landing page, Google sign-in, and after sign-in a generator that calls the OpenAI Images API and stores history.
- Next.js 16 (App Router, TypeScript, Turbopack)
- Auth.js v5 — sign-in via Google or an email link
- Prisma 7 + SQLite — users, sessions and image history
- OpenAI Images API — called exclusively from the server
- MCP server — connects into Claude as a tool
Quick start
npm install
Copy .env.local.example to .env.local and fill in the values (see below).
Then prepare the database and start the app:
npm run db:migrate
npm run dev
The app runs at http://localhost:3000.
Environment variables
.env.local (not committed):
| Variable | What it's for |
|---|---|
AUTH_SECRET |
Cookie signing key. Generate with npx auth secret. |
AUTH_GOOGLE_ID |
Client ID from Google Cloud Console. |
AUTH_GOOGLE_SECRET |
Client secret from the same place. |
OPENAI_API_KEY |
Key from platform.openai.com. Stays on the server. |
OPENAI_IMAGE_MODEL |
Optional, which model is preselected. Default gpt-image-1. |
OPENAI_PRICING_JSON |
Optional, overrides the price list without a new deploy (see Image pricing). |
DAILY_IMAGE_LIMIT |
How many images a user may generate per day, default 20. Does not apply to admins. |
ADMIN_EMAILS |
Admins, comma-separated. At least one is required. |
RESEND_API_KEY |
Optional, invitations and sign-in via link. |
EMAIL_FROM |
Optional, sender for emails. |
LOG_DIR |
Optional, directory for daily log files. Default ./data/logs. |
LOG_RETENTION_DAYS |
Optional, how many days of log files to keep. Default 14. |
LOG_BUFFER_SIZE |
Optional, size of the in-memory ring buffer of recent events. Default 1000. |
LOG_LEVEL |
Optional, minimum log level (error, warn, info, http, verbose, debug). Default info. |
LOG_LEVELS |
Optional, per-source level overrides, e.g. health=verbose,mcp=debug. |
.env only holds DATABASE_URL for the Prisma CLI.
Google OAuth client
- https://console.cloud.google.com/ → pick or create a project.
- APIs & Services → OAuth consent screen — fill in the app name and email. While the app is in Testing mode, add your own account under Test users.
- APIs & Services → Credentials → Create credentials → OAuth client ID, type Web application.
- Under Authorized redirect URIs add exactly:
http://localhost:3000/api/auth/callback/google(for production alsohttps://your-domain/api/auth/callback/google). - Put the generated Client ID and Client secret into
.env.local.
OpenAI key
Key from https://platform.openai.com/api-keys. Image generation is paid —
that's why the app has a per-user daily limit, DAILY_IMAGE_LIMIT.
Structure
prisma/schema.prisma User/Account/Session/VerificationToken + Image models
src/auth.ts Auth.js configuration (Google + Prisma adapter)
src/lib/prisma.ts Prisma client with the better-sqlite3 adapter
src/lib/openai.ts Images API calls and error translation into Czech
src/lib/quota.ts daily limit and spend within a sliding 24h window
src/lib/pricing.ts model price list and token-to-money conversion
src/lib/access.ts who is let in (invitations + ADMIN_EMAILS)
src/lib/email.ts sending via the Resend HTTP API
src/lib/health.ts health check logic behind GET /health
src/lib/logging/ winston setup, ring buffer, log file catalog
src/app/admin/ administration and server actions
src/app/admin/logs/ admin-only Logs page
src/lib/apikeys.ts issuing and verifying API keys
src/lib/download-token.ts signed links for downloading an image
src/lib/generation.ts shared generation path for both web and MCP
src/app/api/mcp/ MCP server (Streamable HTTP)
src/lib/mcp-tools.ts tool definitions, shared by the server and the docs
src/app/app/mcp/ MCP documentation (signed-in users only)
src/lib/storage.ts reading and writing PNGs in the data directory
src/app/page.tsx landing page (public)
src/app/app/ generator, history and detail (signed-in users only)
src/app/api/generate/ server route for both generation and editing
Access to /app is checked by both the layout and every page individually —
Next.js renders them in parallel, so a redirect() in the layout does not
stop the page render. Every API route checks the session itself as well.
Useful commands
npm run typecheck
npm run lint
npm run db:studio
Deployment
The app runs on VPS 37.205.12.163 in a Docker container behind nginx, at
the domain https://gimmeimage.tomlabs.xyz. Deployed by Azure Pipelines
(azure-pipelines.yml) on every push to main.
How it works
- Build stage —
npm ci,prisma generate, lint, typecheck,next buildanddocker build. Sources are published as an artifact. - DeployVps stage (only from
main) — the artifact is packed, sent to the VPS viascp, and deploy/deploy-vps.sh runs. It builds the image directly on the VPS, swaps the container, installs the nginx configuration, lets certbot issue/renew the certificate, and polls/health. Healthy passes. Degraded keeps the new release but fails the run and prints the entries. Unhealthy, or no answer within ~60 s, rolls back to the previous image and release folder. A faileddocker buildleaves the running container untouched. The pipeline also checkshttps://gimmeimage.tomlabs.xyz/healthfrom outside and requires Healthy. After a successful deploy, it publishes the catalogue entry to the TomLabs app store (apps.tomlabs.cz) throughtemplates/publish-web-app.yml@appstore, usingappstore.json,icon.png,README.mdandCHANGELOG.md.
The container listens on 127.0.0.1:5190, nginx proxies the domain to it.
What to prepare before the first run
DNS — done, nothing to do.
gimmeimage.tomlabs.xyzresolves to37.205.12.163(covered by the*.tomlabs.xyzwildcard), via anArecord only, noAAAA— same as the other apps on the VPS, so certbot verifies the domain over IPv4 without issues.Variable group
gimmeimage-deploymentin the Azure DevOps Library:Variable Required What for VPS_SSH_PRIVATE_KEY_B64yes* SSH key of user codexdeploy, base64VPS_SSH_PRIVATE_KEYyes* the same in plain text (alternative) GoogleClientIdno Google OAuth Client ID GoogleClientSecretno Google OAuth Client secret OpenAiApiKeyno key for image generation AdminEmailsyes admins, comma-separated — without them the app lets no one in ResendApiKeyno sending invitation emails EmailFromno sender, domain verified in Resend * one of the two is enough.
Optional variables can be added any time later — the deploy still works without them, just that feature won't work, and the script prints that at the end.
Variable group
appstore-publish(APPSTORE_TOKEN) in the Azure DevOps project — needed by the post-deploy app store publish step.Google OAuth redirect URI — in Google Cloud Console add the production address to the same client:
https://gimmeimage.tomlabs.xyz/api/auth/callback/google
Data and state on the server
Everything persistent lives in /opt/gimmeimage/data, a Docker volume
mapped to /app/data:
gimmeimage.db— SQLite database (users, sessions, history)generated/— generated PNGs
Configuration lives in /opt/gimmeimage/.env (mode 600). AUTH_SECRET is
generated once on first deploy and never changes afterward — otherwise
everyone would get logged out on every deploy. Database migration runs
prisma migrate deploy in the container entrypoint, so the schema is
brought up to date on every start.
Images are not stored under public/ — they would sit outside the
volume and disappear with every deploy, and would also be publicly
reachable by anyone who guesses the file name. They are served by
/api/images/[fileName], which
first verifies the image belongs to the signed-in user.
Manual deployment
The pipeline does the same thing, but in case a manual deploy is needed:
tar --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='data' -czf /tmp/gimmeimage.tar.gz .
scp /tmp/gimmeimage.tar.gz deploy/deploy-vps.sh deploy/nginx-gimmeimage.tomlabs.xyz.conf codexdeploy@37.205.12.163:/tmp/
ssh codexdeploy@37.205.12.163 "bash /tmp/deploy-vps.sh"
Application log
Structured logging with winston: daily rolling JSON log files
gimmeimage-YYYY-MM-DD.log in LOG_DIR (default ./data/logs, in Docker
/app/data/logs on the volume, i.e. /opt/gimmeimage/data/logs on the VPS,
outside the release folder), kept for LOG_RETENTION_DAYS days (default
14). Events also go to the console (docker logs) and to an in-memory ring
buffer (LOG_BUFFER_SIZE, default 1000). LOG_LEVEL sets the minimum level
(error, warn, info, http, verbose, debug; default info). LOG_LEVELS
gives per-source overrides, e.g. health=verbose,mcp=debug.
The Logs page at /admin/logs is administrators only, because the app
is public. It shows live recent events, filterable by minimum level, source
and text, plus the list of log files with a tail view and download
(GET /api/logs/{file}, admins only). Secrets (OpenAI/Resend/GimmeImage API
keys, bearer tokens, signed link signatures, fields named
*token/*secret/*password/apiKey/authorization/cookie) are redacted before
writing. The health probe logs at verbose level, so monitoring doesn't
flood the log.
Code: src/lib/logging/, src/instrumentation.ts
(startup event and request errors). Use getLogger("source") instead of
console.*.
Image pricing
For image models OpenAI returns actual token usage, so the price doesn't need to be estimated from a "size + quality" table. Each image stores:
modeland four token columns (inputTextTokens,inputImageTokens,outputImageTokens,outputTextTokens),costMicroUsd— the price in millionths of a dollar, computed with the rate that was in effect at generation time.
Both are stored deliberately. Tokens are a fact that doesn't age; the
price is a snapshot of a rate. If OpenAI raises prices, the history stays
truthful instead of being silently rewritten. Money is an integer, not a
Float — summing decimals would slowly drift.
The price list is in src/lib/pricing.ts (USD per 1M
tokens, Standard tier). When prices change, it can be overridden via the
OPENAI_PRICING_JSON variable without a new deploy:
OPENAI_PRICING_JSON='{"gpt-image-1":{"imageOutput":45}}'
Listed models get overridden, others keep their default values.
Two limitations worth knowing about:
- For a model that doesn't return
usage, the image is stored without a price. An empty value is better than an estimate posing as a measured amount. - The price list also distinguishes cheaper cached input, but the image generation API doesn't report how many tokens came from cache. So the price may be slightly overestimated in cases where caching applies.
Reference values for gpt-image-1 at 1024×1024, matching OpenAI's
indicative prices: low quality $0.011, medium $0.042, high $0.166.
Version
The semantic version lives in package.json and is baked into the build via
next.config.ts. It's shown in the UI footer, where the
per-deployment release id (a timestamp, added by deploy-vps.sh) appears
as a tooltip on the version and is logged at startup.
Bump the version:
npm version patch
Editing an uploaded image
The form lets you attach your own image alongside the prompt. With an
attachment, a different OpenAI endpoint is called (images.edit) instead
of generation, and the result is tagged "Úprava" (edit) in history (column
kind in the database).
Input limits:
| Formats | PNG, JPEG, WEBP |
| Size | up to 10 MB (MAX_UPLOAD_BYTES in src/lib/images.ts) |
OpenAI can handle up to 50 MB, but there's no point hauling a file that big
through the VPS. When you change the limit, also update
client_max_body_size in the
nginx configuration — otherwise
nginx would reject the request with its own error before it reaches the
app.
Size and format are checked both on the client (so the user doesn't wait for an upload the server will reject) and on the server (because the client check can be bypassed).
The uploaded image is not stored — it's only used as input for OpenAI and then discarded. Only the result is stored. If source images ever need to be archived too, that could go through the same src/lib/storage.ts module.
Editing counts toward the daily limit the same as generation, and is
somewhat more expensive: for gpt-image-1, input image tokens cost $10 per
million versus $5 for text.
Invite-only access
Signing in via Google proves identity, not authorization. Who is let in
is decided by a record in the Invitation table, or an email in
ADMIN_EMAILS. The gate is the signIn callback in
src/auth.ts — without it the adapter would create an account
for anyone with a Google account. A rejected user lands on
/prihlaseni-zamitnuto (login rejected), where they find out why, instead
of bouncing back to the login page.
ADMIN_EMAILS takes precedence over the role stored in the database, so an
admin can't accidentally strip their own access. Such an account is marked
with an env badge in administration and its permissions can't be changed
there — only by changing the variable and redeploying.
Set at least one ADMIN_EMAILS. Without it, no one can reach
administration and the app lets no one in.
The user's path inside
- On the landing page they click Požádat o přístup (request access)
and leave their email. The request lands in
AccessRequestand admins get notified. - An admin turns it into an invitation in
/admin, or rejects it. A rejected email doesn't get back into the queue just by resubmitting the form. - The invited user gets an email with a working sign-in link right away — one click and they're in, no form filling. The link is valid for 24 hours; after that they request a new one in the app, or sign in with Google using the same address. A different address won't get in.
An admin can also invite directly, without a prior request.
Administration
/admin is accessible to admins only and offers:
- pending access requests (invite / reject),
- inviting by email with a role choice,
- a list of everyone with access: role, when invited, when they first signed in, how many images they generated and for how much. Actions: resend invitation, change role, revoke access.
Revoking access deletes the invitation and active sessions — otherwise a revoked user would stay signed in until the cookie expires. Images and the account itself remain.
The gate in the /admin layout isn't enough: server actions aren't
considered part of that route from Next.js's point of view, so every action
checks authorization again itself. The Next.js docs explicitly call this
out.
Emails via Resend
Sent through the Resend HTTP API
with plain fetch, no SDK — the same pattern as in the sister app
RealityAgent.
Set RESEND_API_KEY and EMAIL_FROM (the domain must be verified in
Resend, e.g. GimmeImage <noreply@tomlabs.xyz>). Without them, invitations
still work, they just aren't sent — administration shows this and you
send the link manually. A send failure never invalidates the invitation
itself: the invited user has access either way.
The link in the email is built from the request, not a hardcoded domain, so
it also works in local development. In production it's determined by
AUTH_URL.
MCP server
GimmeImage can be connected to Claude as a tool — Claude can then "generate
me an image" on its own, without writing requests. The endpoint is
/api/mcp, transport Streamable HTTP,
authenticated via an API key in the Authorization: Bearer header.
The server runs without sessions: every request stands on its own, so a container restart doesn't drop an in-progress session.
Tools
| Tool | What it does |
|---|---|
generate_image |
Generates an image from a prompt. Returns the image and its price. |
edit_image |
Edits an image passed as base64. |
get_quota |
How many images remain and how much has been spent. |
list_recent_images |
History: id, prompt, time, price. |
get_image |
Data of a previously generated image, by id. |
API keys
The API is reserved for administrators. A key bypasses both the browser and the daily limit, so it isn't issued to anyone else. The role is also checked on every request, not just when the key is issued: removing admin rights ends access immediately (HTTP 403), without having to manually revoke the key.
Administrators have no daily limit — neither in the browser nor via the
API. That also makes practical sense: whoever can change
DAILY_IMAGE_LIMIT and issue keys is unlimited either way. Spend is still
tracked, though, so it shows up in history and in administration.
Keys are issued by an admin in /admin. A key always belongs to a
specific user — the daily limit and spend are counted against that user,
whether they generate via the browser or via Claude. The user therefore
needs an account, i.e. must have signed in at least once.
The key is shown in plain text only once, at creation; only its SHA-256 is stored. A plain hash is enough here: the key is 256 bits of randomness, not a password — a slow function like bcrypt protects against guessing weak passwords, here it would just slow down every request. Revocation is immediate.
What Claude knows about the server
Nothing is hardcoded in Claude — the server tells it everything at
connection time. The handshake (initialize) returns an instructions
field with the overall guide to the server, and tools/list returns each
tool's name, description and input JSON Schema.
Those texts are effectively a prompt for Claude, so they determine how well
it uses the tools. instructions therefore mentions things that don't
follow from the individual tool descriptions: the typical sequence between
tools, that the daily limit is shared and costs real money, and when to
reach for a cheaper model or lower quality.
Both live in src/lib/mcp-tools.ts, and the
/app/mcp page shows that guide exactly as Claude receives it.
In-app documentation
A signed-in user finds a page at /app/mcp with a connection guide and a
listing of all tools including parameters, types and defaults. It's behind
sign-in, because the app is invite-only — there's no point showing what
runs here to outsiders.
The listing is generated from the tool definitions in src/lib/mcp-tools.ts, which the MCP server itself reads from too. If the docs kept their own copy, they'd silently go stale after the first schema change. Adding a parameter shows up on the page by itself.
Scalar and OpenAPI are deliberately not used: Scalar renders OpenAPI, which describes REST, whereas MCP is JSON-RPC over a single endpoint. OpenAPI wouldn't say anything useful about the tools.
Connecting from Claude Code
Administration prints a ready-to-use command, including the key. It looks like this:
claude mcp add --transport http gimmeimage https://gimmeimage.tomlabs.xyz/api/mcp --header "Authorization: Bearer gi_..."
Isolation between users
Every tool filters by the key's owner. Both get_image and
list_recent_images include userId in their query, so a key holder can't
read someone else's image even by guessing the id — verified by a test
using two keys.
Upload size limit for edits
edit_image sends the image as base64, which inflates it to four thirds of
its size. A ten-megabyte file becomes 13.3 MB in the request, which is why
nginx has client_max_body_size set to 20m. When you change
MAX_UPLOAD_BYTES, update this value too.
Model choice
The user picks the model for each image — in the form, and as the model
parameter in the REST API and in the generate_image and edit_image MCP
tools. OPENAI_IMAGE_MODEL no longer locks the model, it just determines
which one is preselected.
The list of options is derived from the price list in src/lib/pricing.ts: only a model whose price can be computed can be selected. Otherwise history would end up with entries carrying an empty amount. Adding a model to the price list makes it available everywhere at once — in the form, the API and the MCP docs.
DALL·E is deliberately missing from the list: it has different sizes and a different quality scale, so it wouldn't fit the current form and would return errors from OpenAI.
Next to each model in the picker is its output image rate ($/1M tokens).
It's there to compare models against each other — the exact price of an
image can't be computed in advance, it depends on actual token usage, which
OpenAI only returns with the result.
An unknown model returns 400. No model given isn't an error: the default
is used, so older clients that don't send model keep working.
Image detail and deletion
The thumbnail and description in the gallery link to
/app/obrazek/[id] (image detail) — the full-size image plus its prompt,
model, format, quality, price and the token usage breakdown the price is
computed from.
Deletion works from both the gallery and the detail page. It's a two-step
action: the first click reveals a confirmation. Deliberately not
window.confirm — that's easy to tap through by mistake on mobile and
can't be styled.
The database row is deleted first, then the file. If something fails in between, an orphaned file is left on disk, which is less bad than a record pointing at a nonexistent file.
Ownership is checked directly in the query (where: { id, userId }), so
someone else's image can't be viewed or deleted by guessing the id. A
foreign id and a nonexistent id behave the same (404), so it can't be
determined which ones exist.
Sign-in via email link
Besides Google, sign-in also works via a link sent to your inbox. This
covers accounts on domains without Google — typically seznam.cz. No
passwords are stored, and Auth.js supports this natively through its
Resend provider; sending is just redirected through a custom helper for
the Czech text.
The link is single-use and valid for 24 hours. It only appears when Resend
is configured (RESEND_API_KEY, EMAIL_FROM) — without it, only Google
remains.
The invite-only gate applies here too, already at the link request
step. The signIn callback is called twice for this method: once before
the email is sent, again after the click. Rejecting at the first step is
essential — otherwise the app would send emails to any address on request.
The Google provider has allowDangerousEmailAccountLinking enabled.
Without it, someone who first signed in via link and then via Google would
hit OAuthAccountNotLinked. It's called "dangerous" in general because a
provider might not verify the email; Google does verify it, and signing in
via link proves access to the inbox, so the email is a reliable identity
either way. The app is also invite-only, so only an account that already
has access gets linked.
Downloading an image from MCP
MCP returns the image as an image block, but that's meant for the model
to see, not to transfer a file — the client can't get the data out of
it or save it to disk. Returning base64 as text isn't the way either: a
one-megabyte PNG is hundreds of thousands of tokens.
So the tools also return a signed download link in the text. It downloads with a plain GET, no headers, no sign-in required, because the signature replaces the session. Valid for one hour.
The route /api/images/[fileName] accordingly accepts three ways to prove access: a browser session, an API key, or a signed link. Ownership is checked for the session and the key; the signed link is deliberately a transferable credential — it was created at the owner's request and its short lifetime keeps the risk low.
Icons
src/app/icon.svg is the source. node scripts/make-icon.mjs renders
src/app/favicon.ico, src/app/icon.png, src/app/apple-icon.png and the
root icon.png (used by AppShelf and the app store).
Changelog
Changelog
0.9.1 — 2026-09-24
- Changed:
GET /api/versionis back, unchanged, next toGET /health. - Changed: the database health check gives up after 3 seconds and reports
Unhealthy. - Fixed: a failing health check no longer shows the error message in the public
/healthanswer; the details are in the application log. - Changed: health probes are logged at debug level; a status change is logged once as a warning (or as info when the app is healthy again).
0.9.0 — 2026-09-24
- Added:
GET /healthanswersHealthy/Degraded/Unhealthywith the version and individual checks (database, image storage, log folder, configuration). It replaces/api/version. - Added: application log with daily rolling files (14 days by default) and a Logy page in the administration. It shows live events filterable by level, source and text, plus the log files with a tail view and download. Secrets are redacted before anything is written.
- Added: the app has real icon files (
favicon.ico,icon.png,apple-icon.png) and a catalogue entry in the TomLabs app store. - Changed: a deploy that does not come up healthy rolls back to the previous release. A Degraded release stays in place, but the deploy is reported as failed.
- Changed: the README is in English.
0.8.1 — 2026-09-24
- Added: a theme switch in the header with three states, System / Light / Dark. System is the
default and follows the operating system live; Light and Dark pin the look (stored under
gimmeimage.theme.v2). The button shows the current mode (half circle, sun, moon) with its name, and a pinned choice is applied before the first paint, so pages never flash. - Changed: deploy removes the previous release images, so old releases no longer fill the disk.