Camera → OCR
A photo that lands in Nextcloud gets its text saved beside it.
The text is a sibling file, indexed with the image:
receipts/2026-08-14_grocery.jpg
receipts/2026-08-14_grocery.jpg.ocr.txt ← created automatically
Getting the photo in
Any upload to Nextcloud triggers it. Use one route or several.
| Route | Good for | Setup |
|---|---|---|
| Nextcloud auto-upload | The default. Your phone's camera roll syncs, and every photo is OCR'd. No bot, no token. | Turn on the Nextcloud mobile app's auto-upload |
| rclone / a script | Scanners, batch imports, anything already on disk | rclone copyto file.jpg nextcloud:scans/file.jpg |
| Any WebDAV client | Other apps writing to Nextcloud directly | Nothing — the upload is the trigger |
How It Works
1. The image lands in Nextcloud
The flow starts when the upload finishes.
2. Nextcloud fires the webhook
The upload raises a NodeWrittenEvent, which Nextcloud POSTs to the Decree webhook's /files endpoint. This is the same pipeline used for any file uploaded to Nextcloud.
3. file_router matches image extensions
file_router scans the ollama-ocr processor and reads its pattern:
PATTERN='\.(jpg|jpeg|png|webp|gif|heic|heif|tiff?|bmp)$'
IS_PRE_SIGNED=false
A match enqueues a file_processor message with is_pre_signed: false — the file will be downloaded locally.
4. file_processor downloads the image
file_processor runs rclone copyto to pull the image into a temp path and exports it as FILE_PATH. No share link is involved — the full image bytes are needed to encode as base64 for the vision API.
5. ollama-ocr extracts the text
ollama-ocr.sh invokes ocr.ts, which:
- Reads the image from
FILE_PATHand encodes it as base64 - POSTs it as an
image_urlto/chat/completionsat the vision endpoint (EXIST_MODEL_URL_VISION, an OpenAI-compatible/v1URL — ollama or llama.cpp) with the configured vision model and an extraction prompt - Writes the returned text to stdout
The shell processor pipes that output via rclone rcat to Nextcloud at the same path with .ocr.txt appended:
scans/2026-03-02-receipt.jpg
scans/2026-03-02-receipt.jpg.ocr.txt ← created automatically
Prerequisites
- Nextcloud with file events on (see File Processor)
- Ollama running with a vision-capable model pulled (e.g.
llava,llava-phi3,moondream) - rclone configured with a
nextcloudremote
Setup
Step 1 — Pull a vision model in Ollama
docker exec ollama ollama pull llava
Any Ollama-compatible vision model works. llava is a solid general-purpose choice; llava-phi3 is faster on CPU.
Step 2 — turn it on
./existential.sh flow on image-ocr turns on Nextcloud's file events (two migrations: the
nextcloud rclone remote and the webhooks, see
File Processor → Setup) and copies the ollama-ocr
processor into workspace/.decree/lib/file-processors/.
Step 3 — Point a camera at it
Option A — Nextcloud camera auto-upload
In the Nextcloud mobile app, turn on Auto upload.
Option B — rclone
For scanners, batch imports, or anything already on disk:
rclone copyto /path/to/scan.jpg nextcloud:documents/scan.jpg \
--config automation/secrets/rclone/rclone.conf
Customization
| Variable | Default | Description |
|---|---|---|
FILE_SUFFIX | .ocr.txt | Suffix appended to the image path for the OCR output |
OUTPUT_RCLONE | nextcloud | rclone remote where the OCR result is saved |
OCR_MODEL | EXIST_MODEL_VISION | Vision model used for text extraction. Chosen globally in .env.shared, not per-flow. |
EXIST_MODEL_URL_VISION | (blank → EXIST_MODEL_URL) | OpenAI-compatible /v1 URL serving the vision model. Set in .env.shared. |
Testing
Upload a test image straight into Nextcloud to bypass whichever capture route you chose and verify the OCR pipeline on its own — the upload fires the event itself:
rclone copyto /path/to/test.jpg nextcloud:scans/test.jpg \
--config automation/secrets/rclone/rclone.conf
Watch the run complete:
docker logs -f automation
docker exec automation decree status
docker exec automation decree log <id-prefix>
Reusing the OCR function
ocr.ts is a standalone library importable in any other TypeScript script:
import { ocr } from "/work/.decree/lib/ocr";
const text = await ocr("/tmp/some-file.png", {
model: "llava-phi3",
prompt: "What does this receipt total?",
});