TomLabs Apps
← All applications

GimmeImage

Images from a text prompt in seconds, with history, editing and an MCP server for Claude.

web nextjs openai mcp image-generation Public Homepage

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

  1. https://console.cloud.google.com/ → pick or create a project.
  2. 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.
  3. APIs & Services → Credentials → Create credentials → OAuth client ID, type Web application.
  4. Under Authorized redirect URIs add exactly: http://localhost:3000/api/auth/callback/google (for production also https://your-domain/api/auth/callback/google).
  5. 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

  1. Build stage — npm ci, prisma generate, lint, typecheck, next build and docker build. Sources are published as an artifact.
  2. DeployVps stage (only from main) — the artifact is packed, sent to the VPS via scp, 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 failed docker build leaves the running container untouched. The pipeline also checks https://gimmeimage.tomlabs.xyz/health from outside and requires Healthy. After a successful deploy, it publishes the catalogue entry to the TomLabs app store (apps.tomlabs.cz) through templates/publish-web-app.yml@appstore, using appstore.json, icon.png, README.md and CHANGELOG.md.

The container listens on 127.0.0.1:5190, nginx proxies the domain to it.

What to prepare before the first run

  1. DNS — done, nothing to do. gimmeimage.tomlabs.xyz resolves to 37.205.12.163 (covered by the *.tomlabs.xyz wildcard), via an A record only, no AAAA — same as the other apps on the VPS, so certbot verifies the domain over IPv4 without issues.

  2. Variable group gimmeimage-deployment in the Azure DevOps Library:

    Variable Required What for
    VPS_SSH_PRIVATE_KEY_B64 yes* SSH key of user codexdeploy, base64
    VPS_SSH_PRIVATE_KEY yes* the same in plain text (alternative)
    GoogleClientId no Google OAuth Client ID
    GoogleClientSecret no Google OAuth Client secret
    OpenAiApiKey no key for image generation
    AdminEmails yes admins, comma-separated — without them the app lets no one in
    ResendApiKey no sending invitation emails
    EmailFrom no 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.

  3. Variable group appstore-publish (APPSTORE_TOKEN) in the Azure DevOps project — needed by the post-deploy app store publish step.

  4. 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:

  • model and 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

  1. On the landing page they click Požádat o přístup (request access) and leave their email. The request lands in AccessRequest and admins get notified.
  2. 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.
  3. 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.

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

0.9.1 — 2026-09-24

  • Changed: GET /api/version is back, unchanged, next to GET /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 /health answer; 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 /health answers Healthy / Degraded / Unhealthy with 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.