Image Support — Technical Reference
Feature: Image attachments in AI chat conversations (Claude Code & Codex) Status: Implemented Server port: 9545
Table of contents
- HTTP API
- tRPC procedures
- Shared Zod schemas
- Architecture & data flow
- ImageStorageService
- message-images helper
- Runner integration
- ImageCleanupService
- RUNEYA_API_TOKEN environment variable
- Security notes
- 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
| Property | Value |
|---|---|
| Method | POST |
| Path | /api/images/upload |
| Content-Type | multipart/form-data |
| Form field | image (binary file) |
| Max size | 5 MB (MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024) |
| Allowed MIME types | image/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
{
"imageId": "550e8400-e29b-41d4-a716-446655440000",
"url": "http://localhost:9545/api/images/550e8400-e29b-41d4-a716-446655440000",
"mimeType": "image/png",
"originalName": ""
}
originalNameis intentionally empty in the response to avoid leaking user-supplied filenames back to the client.
Error responses
| HTTP status | Body | Condition |
|---|---|---|
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
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
| Parameter | Type | Description |
|---|---|---|
imageId | UUID v4 | Returned 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.
| Header | Value |
|---|---|
Content-Type | MIME type of the stored image (e.g. image/png) |
Cache-Control | no-store |
Error responses
| HTTP status | Body | Condition |
|---|---|---|
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
{
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
{
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:
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).
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.
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.
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
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, ImageUploadResponseSchemaStorage 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/:imageId5. ImageStorageService
File: apps/server/src/services/image-storage.service.tsSingleton export: imageStorageService
| Method | Signature | Description |
|---|---|---|
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):
| Constant | Value | Description |
|---|---|---|
MAX_IMAGE_SIZE_BYTES | 5242880 | 5 MB hard limit |
IMAGE_TTL_MS | 86400000 | 24 h TTL |
ALLOWED_MIME_TYPES | Set<string> | image/png, image/jpeg, image/webp, image/gif |
ALLOWED_EXTENSIONS | Set<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
| Function | Signature | Description |
|---|---|---|
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. |
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-pflag) - A multimodal message is written to stdin as a single JSON line (Anthropic API format):
{
"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
--imagenative flag in the tested Claude Code CLI version. Thestream-jsonstdin 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):
[
{
"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
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
class ImageCleanupService {
start(intervalMs?: number): void // default: 3_600_000 ms (1 hour)
stop(): void
runCleanup(): Promise<{ deleted: number; errors: number }>
}Behavior
| Method | Description |
|---|---|
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
// 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] StoppedA 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:
// 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.list10. Security notes
| Finding | Status | Impact |
|---|---|---|
| Path traversal prevention | ✅ Implemented | saveImage validates resolved path stays inside TMP_IMAGES_DIR |
UUID v4 from node:crypto | ✅ Implemented | No Math.random() fallback |
File permissions: 0o600 (files), 0o700 (directory) | ✅ Implemented | Images not readable by other OS users |
| MIME type whitelist | ✅ Implemented | Only png/jpeg/webp/gif accepted |
originalName omitted from API response | ✅ Implemented | Prevents filename reflection in response |
imageId redacted from logs | ✅ Implemented | Prevents enumeration via log scraping |
RUNEYA_API_TOKEN via env var (not CLI args) | ✅ Implemented | Not visible in ps aux |
Auth on POST /api/images/upload in LAN mode | ⚠️ Planned | Sprint N+1; JWT middleware to be applied when RUNEYA_LAN_PROXY=true |
Rate limiting on POST /api/images/upload | ⚠️ Planned | express-rate-limit 20 req/min/IP (IS-HIGH-2) |
Zod validation of .meta.json before use | ✅ Implemented | ImageMetaSchema.parse(raw) in getImagePath and listExpiredImages |
Do not:
- Return
originalNamefrom upload responses - Log raw
imageIdvalues in server output - Pass
apiTokenas a CLI argument
11. Contribution guide — tests
Test files
All image-support tests live in apps/server/src/__tests__/:
| File | What it covers |
|---|---|
image-storage.test.ts | ImageStorageService — save, retrieve, delete, TTL, path traversal, MIME validation |
image-storage-extra.test.ts | Edge cases: duplicate IDs, concurrent writes, large buffers |
image-cleanup.test.ts | ImageCleanupService — TTL expiry, start/stop idempotency, error counting |
message-images.test.ts | extractImagesFromMessage, resolveImagesToBase64, missing-image skip |
message-images-extra.test.ts | Batch resolution edge cases |
runner-images.test.ts | buildCodexMultimodalStdin (JSON format, MIME data-URL, block order), buildClaudeCodeContentBlocks (Anthropic format, text-first order, 5-image limit) |
Running the tests
# 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 testShared schema tests
yarn workspace @runeya/packages-shared vitest run src/__tests__/image.schema.test.tsKey files to update when changing image behavior
| Change | Files to update |
|---|---|
| New MIME type | ALLOWED_IMAGE_MIME_TYPES in image.schema.ts, ALLOWED_MIME_TYPES and ALLOWED_EXTENSIONS in image-storage.service.ts |
| Change TTL | IMAGE_TTL_MS in image-storage.service.ts |
| Change cleanup interval | imageCleanupService.start(intervalMs) in create-server.ts |
| New runner | Implement AiRunnerParams.images consumption; add test in runner-images.test.ts |
| Add image field to API response | Update ImageUploadResponseSchema in image.schema.ts, rebuild shared package before testing |
PR checklist (image-support specific)
- [ ]
yarn buildpasses (TypeScript strict, noanyon image types) - [ ]
yarn testpasses, including all__tests__/image-*.test.tsandrunner-images.test.ts - [ ] New MIME types added to both
image.schema.tsandimage-storage.service.ts - [ ] No
imageIdlogged in plain text - [ ] No
apiTokenpassed as a CLI argument - [ ]
originalNamenot reflected in API responses