Skip to content

Image Support — Technical Reference

Feature: Image attachments in AI chat conversations (Claude Code & Codex) Status: Implemented Server port: 9545


Table of contents

  1. HTTP API
  2. tRPC procedures
  3. Shared Zod schemas
  4. Architecture & data flow
  5. ImageStorageService
  6. message-images helper
  7. Runner integration
  8. ImageCleanupService
  9. RUNEYA_API_TOKEN environment variable
  10. Security notes
  11. Contribution guide — tests

1. HTTP API

These two endpoints are plain Express routes (not tRPC) because tRPC does not handle multipart/form-data natively. Both are registered in apps/server/src/create-server.ts after the tRPC middleware.

POST /api/images/upload

Upload an image to the temporary store.

Request

PropertyValue
MethodPOST
Path/api/images/upload
Content-Typemultipart/form-data
Form fieldimage (binary file)
Max size5 MB (MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024)
Allowed MIME typesimage/png, image/jpeg, image/webp, image/gif

Authentication In default local mode (HOST=127.0.0.1) the endpoint is public — the port is only reachable from the local machine. In LAN mode (RUNEYA_LAN_PROXY=true) the endpoint must be protected by the existing JWT middleware; this is planned for a future sprint (see Security notes).

Successful response — 200

json
{
  "imageId": "550e8400-e29b-41d4-a716-446655440000",
  "url": "http://localhost:9545/api/images/550e8400-e29b-41d4-a716-446655440000",
  "mimeType": "image/png",
  "originalName": ""
}

originalName is intentionally empty in the response to avoid leaking user-supplied filenames back to the client.

Error responses

HTTP statusBodyCondition
400{ "error": "No image file provided" }No file in the multipart body
400{ "error": "Unsupported MIME type: <type>" }MIME not in allowed list
400{ "error": "Image too large: <n> bytes (max 5242880)" }File exceeds 5 MB
500{ "error": "Internal server error" }Filesystem write failure

Example with curl

bash
curl -X POST http://localhost:9545/api/images/upload \
  -F "image=@/path/to/screenshot.png"

GET /api/images/:imageId

Serve a stored image. Used by the frontend to render images in the chat history via <img src="...">.

Path parameter

ParameterTypeDescription
imageIdUUID v4Returned by the upload endpoint

Authentication This endpoint is public in all modes. The UUID v4 format provides 128 bits of entropy — an imageId cannot be guessed or brute-forced in practice.

Successful response — 200

Binary image data streamed via fs.createReadStream.

HeaderValue
Content-TypeMIME type of the stored image (e.g. image/png)
Cache-Controlno-store

Error responses

HTTP statusBodyCondition
400{ "error": "Invalid imageId" }imageId does not match UUID v4 regex
404{ "error": "Image not found" }Unknown or TTL-expired imageId
500{ "error": "Internal server error" }Unexpected read error

2. tRPC procedures

Both sendViaClaudeCode and sendViaCodex accept an optional imageIds field.

chat.sendViaClaudeCode — extended input

typescript
{
  conversationId: string;    // UUID
  prompt: string;
  isResume?: boolean;
  imageIds?: string[];       // max 5 UUID v4 strings
}

When imageIds is provided and non-empty, the runner switches to --input-format stream-json and injects a multimodal user message on stdin (Anthropic format). See Runner integration.

chat.sendViaCodex — extended input

typescript
{
  conversationId: string;    // UUID
  prompt: string;
  model?: string;
  imageIds?: string[];       // max 5 UUID v4 strings
}

When imageIds is provided, images are resolved to base64 and injected as an OpenAI-format content array on stdin.

UUID validation applied to both procedures:

typescript
z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)

Images whose imageId cannot be resolved (expired or unknown) are silently skipped with a console.warn — the request is not aborted.


3. Shared Zod schemas

File: packages/shared/src/schemas/image.schema.ts

All types below are exported from @runeya/packages-shared.

ImageMetaSchema

Persisted alongside each image file as <uuid>.<ext>.meta.json. Validated with Zod before use (DS-HIGH-1 mitigation).

typescript
export const ImageMetaSchema = z.object({
  imageId: z.string().regex(/^[0-9a-f]{8}-...-[0-9a-f]{12}$/i, 'Must be a valid UUID'),
  filePath: z.string().min(1),
  mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
  originalName: z.string().min(1),
  createdAt: z.number().positive(),   // Unix timestamp (ms)
  size: z.number().positive().optional(),
});
export type ImageMeta = z.infer<typeof ImageMetaSchema>;

ImagePartSchema

Embedded in message parts arrays to reference an uploaded image. Stored in conversation history.

typescript
export const ImagePartSchema = z.object({
  type: z.literal('image'),
  imageId: z.string().regex(/^[0-9a-f]{8}-...-[0-9a-f]{12}$/i, 'Must be a valid UUID'),
  url: z.string().min(1),                               // server URL for frontend display
  mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
  altText: z.string().max(500).optional(),
});
export type ImagePart = z.infer<typeof ImagePartSchema>;

ImageUploadResponseSchema

Shape of the JSON body returned by POST /api/images/upload.

typescript
export const ImageUploadResponseSchema = z.object({
  imageId: z.string().regex(/^[0-9a-f]{8}-...-[0-9a-f]{12}$/i, 'Must be a valid UUID'),
  url: z.string().min(1),
  mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
  originalName: z.string(),   // always '' in server responses
});
export type ImageUploadResponse = z.infer<typeof ImageUploadResponseSchema>;

ALLOWED_IMAGE_MIME_TYPES

typescript
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const;

4. Architecture & data flow

Components

apps/web (Vue 3)
  └─ ImageUploadBar.vue           — file picker, thumbnail preview, upload trigger
  └─ ai-chat.store.ts             — pendingImages state, uploadImage(), clearPendingImages()
  └─ AiChatPage.vue               — renders image parts in history, calls sendMessage

apps/server (Node.js / Express + tRPC)
  └─ create-server.ts             — registers POST /api/images/upload and GET /api/images/:imageId
  └─ services/image-storage.service.ts   — writes/reads files in ~/.runeya/tmp/images/
  └─ services/image-cleanup.service.ts   — TTL-based periodic cleanup
  └─ services/message-images.ts          — helpers: extract image parts, resolve to base64
  └─ services/ai-runners/claude-code-runner.ts  — multimodal stdin injection (Anthropic format)
  └─ trpc/routers/chat.ts               — sendViaClaudeCode + sendViaCodex with imageIds

packages/shared
  └─ src/schemas/image.schema.ts  — ImageMetaSchema, ImagePartSchema, ImageUploadResponseSchema

Storage location

~/.runeya/tmp/images/
  ├─ <uuid>.png               — image file (permissions: 0o600)
  ├─ <uuid>.png.meta.json     — ImageMeta JSON (permissions: 0o600)
  └─ ...

Directory is created at server startup with mode: 0o700 (accessible only to the process owner).

Complete message send sequence

1. User selects file.png in ImageUploadBar
2. store.uploadImage(file) is called
   a. URL.createObjectURL(file) → localPreviewUrl (thumbnail only)
   b. fetch POST /api/images/upload → { imageId, url, mimeType }
   c. pendingImages.push({ imageId, url, mimeType, localPreviewUrl })
3. User types text and clicks Send
4. AiChatPage.sendMessage() builds enriched message:
   { role: 'user', content: text, parts: [{ type: 'image', imageId, url, mimeType }] }
5. store.sendMessage(enrichedMessage) → tRPC chat.sendViaClaudeCode / sendViaCodex
6. store.clearPendingImages() — revokes all ObjectURLs, empties array
7. Backend: extracts imageIds from message parts
   → resolveImagesToBase64(imageIds) → [{ imageId, base64, mimeType }]
8. Runner receives resolved images and injects them into the CLI invocation
9. Claude Code / Codex responds; stream flows back via tRPC subscription
10. Frontend renders history: <img :src="part.url"> fetches GET /api/images/:imageId

5. ImageStorageService

File: apps/server/src/services/image-storage.service.tsSingleton export: imageStorageService

MethodSignatureDescription
ensureTmpDir()Promise<void>Creates ~/.runeya/tmp/images/ with mode 0o700 if it does not exist. Idempotent.
saveImage(buffer, originalName, mimeType)Promise<StoredImage>Validates MIME and size, generates a UUID, writes <uuid>.<ext> (mode 0o600) and <uuid>.<ext>.meta.json (mode 0o600). Throws on validation failure.
getImagePath(imageId)Promise<{ filePath, mimeType } | null>Scans the directory for a matching .meta.json, validates it via ImageMetaSchema, returns the file path or null if not found/missing.
deleteImage(imageId)Promise<void>Removes both the image file and its .meta.json. Errors are silently ignored (file already deleted).
listExpiredImages()Promise<ImageMeta[]>Returns all images whose createdAt is older than IMAGE_TTL_MS (24 h). Skips malformed meta files.
readImageAsBase64(imageId)Promise<{ base64, mimeType } | null>Reads the image file and returns it as a base64 string with its MIME type. Returns null if not found.

Constants (exported):

ConstantValueDescription
MAX_IMAGE_SIZE_BYTES52428805 MB hard limit
IMAGE_TTL_MS8640000024 h TTL
ALLOWED_MIME_TYPESSet<string>image/png, image/jpeg, image/webp, image/gif
ALLOWED_EXTENSIONSSet<string>.png, .jpg, .jpeg, .webp, .gif

Path traversal prevention: saveImage resolves the candidate file path and asserts it starts with the resolved TMP_IMAGES_DIR before writing. An Invalid file path error is thrown if not.


6. message-images helper

File: apps/server/src/services/message-images.ts

FunctionSignatureDescription
extractImagesFromMessage(parts)MessageImageRef[]Extracts all { type: 'image' } entries from a message parts array. Returns [] if no images.
resolveImageToBase64(imageId)Promise<{ base64, mimeType } | null>Delegates to imageStorageService.readImageAsBase64. Returns null for missing/expired images.
resolveImagesToBase64(images)Promise<Array<{ imageId, base64, mimeType, altText? }>>Batch resolution. Missing images are silently skipped with console.warn('[message-images] Image not found (imageId redacted from log)'). The imageId is never logged to prevent enumeration.
typescript
export interface MessageImageRef {
  imageId: string;
  mimeType: string;
  altText?: string;
}

7. Runner integration

Claude Code runner

File: apps/server/src/services/ai-runners/claude-code-runner.ts

When images.length > 0:

  • The CLI is invoked with --input-format stream-json (instead of the -p flag)
  • A multimodal message is written to stdin as a single JSON line (Anthropic API format):
json
{
  "role": "user",
  "content": [
    { "type": "text", "text": "<prompt>" },
    {
      "type": "image",
      "source": {
        "type": "base64",
        "media_type": "image/png",
        "data": "<base64-encoded-bytes>"
      }
    }
  ]
}

Text is placed before images, following Anthropic recommendations.

There is no --image native flag in the tested Claude Code CLI version. The stream-json stdin approach is the authoritative method.

Codex runner

File: apps/server/src/trpc/routers/chat.ts (inline in sendViaCodex)

When imageIds.length > 0, images precede the text block (OpenAI vision convention):

json
[
  {
    "type": "image_url",
    "image_url": { "url": "data:image/png;base64,<base64>" }
  },
  {
    "type": "text",
    "text": "<fullPrompt>"
  }
]

This array is written to the Codex process stdin as JSON.

When imageIds is absent or empty, the plain text prompt is passed unchanged — no behavioral regression.

AiRunnerParams extension

File: apps/server/src/services/ai-runners/ai-runner.ts

typescript
export interface AiRunnerParams {
  // ... existing fields ...
  images?: Array<{
    imageId: string;
    base64: string;
    mimeType: string;
    altText?: string;
  }>;
}

8. ImageCleanupService

File: apps/server/src/services/image-cleanup.service.tsSingleton export: imageCleanupService

Public interface

typescript
class ImageCleanupService {
  start(intervalMs?: number): void               // default: 3_600_000 ms (1 hour)
  stop(): void
  runCleanup(): Promise<{ deleted: number; errors: number }>
}

Behavior

MethodDescription
start(intervalMs = 3_600_000)Runs one cleanup immediately (fire-and-forget) then starts a periodic interval. Idempotent — no-op if already running. The interval is .unref()ed so it does not block process shutdown.
stop()Calls clearInterval and sets intervalId = null.
runCleanup()Calls imageStorageService.listExpiredImages(), then deleteImage() on each expired entry via Promise.all. Logs the result including directory size. Individual delete errors are counted and logged without stopping the run.

Expiry criteria

An image is expired when Date.now() - createdAt > IMAGE_TTL_MS (i.e. older than 24 hours).

Server lifecycle integration

typescript
// create-server.ts — startup
await imageStorageService.ensureTmpDir();
imageCleanupService.start();   // immediate cleanup + 1-hour interval

// create-server.ts — graceful shutdown
imageCleanupService.stop();

Conversation deletion

When chat.conversations.delete is called, all imageId values from the conversation's messages are extracted and imageStorageService.deleteImage() is called for each (fire-and-forget via Promise.all). This frees disk space immediately without waiting for the TTL.

Log output

[image-cleanup] Started — interval 3600s
[image-cleanup] Cleanup done — deleted: 3, errors: 0, dir_size: 0.42 MB, files: 8
[image-cleanup] WARN — directory size 512.00 MB exceeds 500 MB threshold
[image-cleanup] Failed to delete <uuid>: ENOENT: no such file or directory
[image-cleanup] Stopped

A WARN-level log is emitted when the directory exceeds 500 MB (WARN_SIZE_BYTES = 500 * 1024 * 1024).


9. RUNEYA_API_TOKEN environment variable

Role

RUNEYA_API_TOKEN holds the JWT used by AI runners (Claude Code, Codex) to authenticate back-calls to the Runeya tRPC API during a session. The runners need this token to call endpoints such as service listing when generating context-aware responses.

Format

Standard Bearer JWT. Example value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Injection mechanism

The token is never passed as a CLI argument (which would expose it in /proc/PID/cmdline and ps aux). Instead, it is injected as an environment variable into the child process:

typescript
// apps/server/src/services/ai-runners/claude-code-runner.ts
spawnSync('claude', args, {
  env: {
    ...process.env,
    ...(apiToken ? { RUNEYA_API_TOKEN: apiToken } : {}),
  },
});

The llms-txt-generator.ts informs AI runners that they should use this variable:

Authentication: pass `Authorization: Bearer $RUNEYA_API_TOKEN` on all tRPC requests.

Usage in API documentation injected to runners

Auth header: Authorization: Bearer $RUNEYA_API_TOKEN

curl -H "Authorization: Bearer $RUNEYA_API_TOKEN" \
  http://localhost:9545/api/trpc/service.list

10. Security notes

FindingStatusImpact
Path traversal prevention✅ ImplementedsaveImage validates resolved path stays inside TMP_IMAGES_DIR
UUID v4 from node:crypto✅ ImplementedNo Math.random() fallback
File permissions: 0o600 (files), 0o700 (directory)✅ ImplementedImages not readable by other OS users
MIME type whitelist✅ ImplementedOnly png/jpeg/webp/gif accepted
originalName omitted from API response✅ ImplementedPrevents filename reflection in response
imageId redacted from logs✅ ImplementedPrevents enumeration via log scraping
RUNEYA_API_TOKEN via env var (not CLI args)✅ ImplementedNot visible in ps aux
Auth on POST /api/images/upload in LAN mode⚠️ PlannedSprint N+1; JWT middleware to be applied when RUNEYA_LAN_PROXY=true
Rate limiting on POST /api/images/upload⚠️ Plannedexpress-rate-limit 20 req/min/IP (IS-HIGH-2)
Zod validation of .meta.json before use✅ ImplementedImageMetaSchema.parse(raw) in getImagePath and listExpiredImages

Do not:

  • Return originalName from upload responses
  • Log raw imageId values in server output
  • Pass apiToken as a CLI argument

11. Contribution guide — tests

Test files

All image-support tests live in apps/server/src/__tests__/:

FileWhat it covers
image-storage.test.tsImageStorageService — save, retrieve, delete, TTL, path traversal, MIME validation
image-storage-extra.test.tsEdge cases: duplicate IDs, concurrent writes, large buffers
image-cleanup.test.tsImageCleanupService — TTL expiry, start/stop idempotency, error counting
message-images.test.tsextractImagesFromMessage, resolveImagesToBase64, missing-image skip
message-images-extra.test.tsBatch resolution edge cases
runner-images.test.tsbuildCodexMultimodalStdin (JSON format, MIME data-URL, block order), buildClaudeCodeContentBlocks (Anthropic format, text-first order, 5-image limit)

Running the tests

bash
# All server tests
yarn workspace @runeya/apps-server vitest run

# Image-support tests only
yarn workspace @runeya/apps-server vitest run --reporter=verbose src/__tests__/image-storage.test.ts src/__tests__/image-cleanup.test.ts src/__tests__/message-images.test.ts src/__tests__/runner-images.test.ts

# Full test suite
yarn test

Shared schema tests

bash
yarn workspace @runeya/packages-shared vitest run src/__tests__/image.schema.test.ts

Key files to update when changing image behavior

ChangeFiles to update
New MIME typeALLOWED_IMAGE_MIME_TYPES in image.schema.ts, ALLOWED_MIME_TYPES and ALLOWED_EXTENSIONS in image-storage.service.ts
Change TTLIMAGE_TTL_MS in image-storage.service.ts
Change cleanup intervalimageCleanupService.start(intervalMs) in create-server.ts
New runnerImplement AiRunnerParams.images consumption; add test in runner-images.test.ts
Add image field to API responseUpdate ImageUploadResponseSchema in image.schema.ts, rebuild shared package before testing

PR checklist (image-support specific)

  • [ ] yarn build passes (TypeScript strict, no any on image types)
  • [ ] yarn test passes, including all __tests__/image-*.test.ts and runner-images.test.ts
  • [ ] New MIME types added to both image.schema.ts and image-storage.service.ts
  • [ ] No imageId logged in plain text
  • [ ] No apiToken passed as a CLI argument
  • [ ] originalName not reflected in API responses

Publié sous licence MIT.