Camera → OCR
Photograph a receipt, a document, a whiteboard, a page of handwriting — and get its text back, filed next to the image, without opening anything.
The flow is image lands in storage → text comes out beside it. How the photo gets into storage is a detail you choose; the OCR half is the same either way.
The text lands as a sibling file, so the image and its contents stay together and both get picked up by whatever indexes that folder next:
receipts/2026-08-14_grocery.jpg
receipts/2026-08-14_grocery.jpg.ocr.txt ← created automatically
Getting the photo in
Anything that writes to a watched bucket triggers the flow. Pick whichever you'll actually use — the OCR pipeline does not care which one you chose, and you can run several at once.
| 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. | Point the Nextcloud mobile app's auto-upload at a folder backed by SeaweedFS |
| A Telegram bot | Deliberate capture — you choose what gets sent, rather than everything you photograph | Option B |
| rclone / a script | Scanners, batch imports, anything already on disk | rclone copyto file.jpg s3:bucket/path.jpg |
| Any S3 client | Other apps writing to the bucket directly | Nothing — the bucket event is the trigger |
If you already run Nextcloud, camera auto-upload is the shortest path to this flow working: photograph something, and the text exists a few seconds later. The Telegram route is worth it when you want a deliberate inbox rather than your whole camera roll.
How It Works
1. The image lands in a watched bucket
However it got there — camera sync, a bot, rclone, another app — the flow starts the moment the object exists. Nothing polls the image itself.
2. SeaweedFS fires the webhook
When the image file lands in the bucket, an ObjectCreated event is POSTed to the Decree webhook endpoint. This is the same pipeline used for any file arriving in a watched bucket.
3. s3-router matches image extensions
s3-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 pre-signed URL 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 to
http://ollama:11434/api/generatewith 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:
telegram/1745000000_AgADjk.jpg
telegram/1745000000_AgADjk.jpg.ocr.txt ← created automatically
Prerequisites
- SeaweedFS receiving file events and forwarding them to the Decree webhook (see File Processor)
- Nextcloud + SeaweedFS configured with SeaweedFS as S3 external storage (so images land in both)
- Ollama running with a vision-capable model pulled (e.g.
llava,llava-phi3,moondream) - rclone configured with a
nextcloudremote
Nothing on that list is specific to how you take the picture.
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 — object-store webhook and rclone
Follow the SeaweedFS setup in File Processor to add your image path to path_prefixes in notification.toml (already done for the nextcloud bucket), and ensure your rclone nextcloud remote is configured:
./existential.sh run rclone
Step 3 — Point a camera at it
That is the whole OCR pipeline. Now pick how images arrive.
Option A — Nextcloud camera auto-upload
In the Nextcloud mobile app, turn on Auto upload and target a folder that lives on the SeaweedFS-backed external storage. Every photo you take syncs up and gets OCR'd. There is nothing else to configure — the bucket event is the trigger.
Option B — Telegram bot
Use this when you want a deliberate inbox instead of your whole camera roll: you photograph something and choose to send it.
Create the bot via @BotFather with /newbot and copy the token, then:
mkdir -p automation/secrets/telegram
cat > automation/secrets/telegram/credentials.env << 'EOF'
TELEGRAM_BOT_TOKEN=your-token-here
EOF
The secrets directory is bind-mounted into the decree container at /secrets/telegram/.
Enable telegram-ingest in services/automation/decree/config.yml (the rendered, gitignored
config the daemon actually reads — not automation/config.yml, which is an empty
Docker-created mountpoint placeholder and has no effect):
shared_routines:
telegram-ingest:
enabled: true
Copy and activate the example cron:
cp automation-examples/cron/telegram-poll.md \
automation/cron/
telegram-ingest polls getUpdates every minute, tracks a cursor in
/secrets/telegram/offset.txt so nothing is processed twice, and pipes each new photo through
rclone rcat into TELEGRAM_RCLONE_DEST. From there it is an ordinary bucket event and the
flow above takes over. Cron frontmatter is read when the daemon starts, so restart it to pick
the new schedule up:
docker compose restart automation
Option C — rclone
For scanners, batch imports, or anything already on disk:
rclone copyto /path/to/scan.jpg s3:documents/scan.jpg \
--config automation/secrets/rclone/rclone.conf
Customization
| Variable | Default | Description |
|---|---|---|
TELEGRAM_RCLONE_DEST | nextcloud:S3/telegram | Telegram route only — where downloaded images are stored (triggers the OCR pipeline) |
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 | Ollama vision model used for text extraction. Chosen globally in .env.shared, not per-flow. |
OLLAMA_URL | http://ollama:11434 | Ollama API base URL |
Testing
Drop a test image straight into the bucket to bypass whichever capture route you chose and verify the OCR pipeline on its own:
rclone copyto /path/to/test.jpg nextcloud:S3/telegram/test.jpg \
--config automation/secrets/rclone/rclone.conf
Then send a synthetic file event:
# automation-webhook publishes no host port — it is reached over the exist
# bridge, so send the event from a container already on it.
docker exec automation curl -X POST http://automation-webhook:8801/s3 \
-H "Authorization: Bearer <EXIST_DECREE_S3_WEBHOOK_AUTH_TOKEN from .env.shared>" \
-H "Content-Type: application/json" \
-d '{"EventName":"s3:ObjectCreated:Put","Key":"telegram/test.jpg","Records":[]}'
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 routine:
import { ocr } from "/work/.decree/lib/ocr";
const text = await ocr("/tmp/some-file.png", {
model: "llava-phi3",
prompt: "What does this receipt total?",
});