Skip to main content

File Processor

Mechanism, not a flow

How a file landing in Nextcloud starts a job. For what the system does, see Flows.

Reacts to files created, updated, renamed or deleted anywhere in Nextcloud. For the workspace, use a file handler instead; it needs no Nextcloud.

  • A file event reaches the Decree webhook; file_router matches the path against your processors and emits one file_processor job per match.
  • Each job downloads the file (or makes a share link if IS_PRE_SIGNED=true), runs the processor, and deletes the local copy.
  • PATTERN: a regex over the path, checked by the router.
  • CRITERIA (optional): a plain-English test of the content, asked of the model after download, so only files that pass PATTERN cost a model call.

How It Works​

1. Nextcloud fires the event​

Nextcloud's built-in webhook_listeners app does the sending. The nextcloud-file-events migration registers three webhooks against http://automation-webhook:8801/files, and every upload through Nextcloud — desktop client, web UI, mobile app, WebDAV, rclone — queues one:

{
"event": {
"node": { "id": 91, "path": "/admin/files/workspace/notes/plan.md" },
"class": "OCP\\Files\\Events\\Node\\NodeWrittenEvent"
},
"user": { "uid": "admin", "displayName": "admin" },
"time": 1790362082
}

NodeWrittenEvent covers a new file and an overwrite; NodeDeletedEvent has the same shape; NodeRenamedEvent carries source and target instead of node. The webhook is sent by a background job, which the nextcloud-cron container delivers as soon as it is queued. A delivery that fails is logged by Nextcloud and not retried.

The request includes Authorization: Bearer <EXIST_DECREE_FILES_WEBHOOK_AUTH_TOKEN>.

Only uploads through Nextcloud fire here

Hermes, code-server and decree write the workspace straight to disk. Nextcloud notices those files (so your desktop client gets them) but raises no event for them. A file handler does see them.

2. file_router — match and fan out​

file_router strips the /<user>/files/ prefix, constructs FILE_SOURCE as nextcloud:<path> (e.g. nextcloud:workspace/notes/plan.md), and scans every script in automation/lib/file_processors/ for a PATTERN= regex match. It maps written → created, deleted → removed, and splits a rename into a removal of the old path plus a creation of the new one. It also reads each processor's IS_PRE_SIGNED= setting to carry it into the job.

A payload does not say whether the path is a file or a folder, and a new folder raises NodeWrittenEvent too. Anchor a PATTERN on an extension and you never notice.

For each matching processor it emits one file_processor message:

---
machine: file_processor
params:
rclone_path: "nextcloud:workspace/notes/plan.md"
processor: my-processor
file_action: created
is_pre_signed: false
---

Multiple processors can match the same file — each runs independently as its own decree run.

3. file_processor — download (or reference), run, clean up​

file_processor exports the standard FILE_* env vars, runs the matched processor script, then deletes any local temp file.

  • Default (IS_PRE_SIGNED=false): downloads the file via rclone copyto to a temp path; FILE_PATH is the local file.
  • Pre-signed (IS_PRE_SIGNED=true): skips the download and instead calls rclone link to generate a share link; PRE_SIGNED_URL is set to that URL and FILE_PATH remains empty. Useful when the processor needs to hand off the file to an external service rather than read it locally.
  • removed events: download is always skipped; FILE_PATH is empty.

Adding a File Processor​

Create automation/lib/file_processors/<name>.sh:

#!/usr/bin/env bash
# PATTERN is matched against FILE_SOURCE: "nextcloud:<path in Nextcloud>"
PATTERN="^nextcloud:documents/.*\.pdf$"
# CRITERIA is optional. Empty = the path match is the whole test.
CRITERIA=""
IS_PRE_SIGNED=false

set -euo pipefail

if [ "$FILE_ACTION" = "removed" ]; then
echo "Deleted: $FILE_KEY"
exit 0
fi

# FILE_PATH is the local temp file (do not delete — file_processor handles cleanup).
# PRE_SIGNED_URL is set instead of FILE_PATH when IS_PRE_SIGNED=true.
echo "Processing $FILE_PATH"

# your logic here — ask the model (lib/model.sh), run a script, POST to an API, etc.

Available env vars:

VariableExampleDescription
FILE_SOURCEnextcloud:docs/file.pdfFull rclone source path
FILE_KEYdocs/file.pdfPath after the remote: prefix
FILE_ACTIONcreated | removedEvent type
FILE_PATH/tmp/file.pdf.xK3rQpLocal temp file (empty for removed or when IS_PRE_SIGNED=true)
PRE_SIGNED_URLhttps://…Share link (set when IS_PRE_SIGNED=true, otherwise empty)
FILE_MATCH_REASONNames a vendor and a due dateThe model's one-line reason the criteria gate passed (empty when CRITERIA is unset)

Script settings:

SettingDefaultDescription
PATTERN(required)Regex matched against FILE_SOURCE
CRITERIA(empty)Plain-English test of the file's content; empty means the path match is the whole test
IS_PRE_SIGNEDfalseIf true, skip download and set PRE_SIGNED_URL instead

Pattern tips:

PATTERN="^nextcloud:Photos/.*\.(jpg|jpeg|png)$" # one folder + extension
PATTERN="^nextcloud:.*\.pdf$" # anywhere, PDFs only
PATTERN="^nextcloud:workspace/.*\.md$" # markdown in the workspace
PATTERN=".*\.csv$" # any rclone remote, CSVs

Decree picks up the new file immediately — no restart needed.

Setup​

./existential.sh flow on nextcloud-capture turns on Nextcloud and automation-webhook and copies the two decree migrations below into volumes/workspace_data/.decree/migrations/. Core does not include Nextcloud, so this is always a step you take. The flows built on it (image-ocr, transcription, workspace-agent) turn it on themselves and copy their processor in:

  • 23-nextcloud-rclone-remote.md — the nextcloud rclone remote (WebDAV, as the admin user) that file_processor downloads through. rclone_src on the webhook's /files route names it.
  • 27-nextcloud-file-events.md — registers the three webhooks.

Nextcloud's own side needs nothing: nas/nextcloud/hooks/before-starting/02-workspace.sh sets allow_local_remote_servers (without it Nextcloud refuses to POST to a private address), and nextcloud-cron runs the background jobs that actually send the webhooks.

Testing​

Upload a file and watch it route:

docker exec automation sh -c 'printf hello | curl -fsS -u "$NEXTCLOUD_ADMIN_USER:$NEXTCLOUD_ADMIN_PASSWORD" \
-T - "http://nextcloud/remote.php/dav/files/$NEXTCLOUD_ADMIN_USER/hello.txt"'
docker logs -f automation

Or send a test event directly to the webhook, skipping Nextcloud:

# 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/files \
-H "Authorization: Bearer <EXIST_DECREE_FILES_WEBHOOK_AUTH_TOKEN from .env.shared>" \
-H "Content-Type: application/json" \
-d '{"event":{"node":{"path":"/admin/files/documents/hello.txt"},"class":"OCP\\Files\\Events\\Node\\NodeWrittenEvent"}}'

Inspect the run log after it completes:

docker exec automation decree status
docker exec automation decree status <id>

Matching on content, not just path​

Add a CRITERIA line and file_processor puts the downloaded file to the model before running your script:

PATTERN="^nextcloud:workspace/.*\.md$"
CRITERIA="an open question the author has not resolved"

The gate is deliberately stingy — it answers NO unless the document genuinely matches, because a YES usually costs a full agent run downstream. On a match, FILE_MATCH_REASON carries the model's one-line reason into your script.

Three things to know:

  • It costs one model call per file that got past PATTERN. Keep the pattern narrow enough that the gate is not asked about everything.
  • It only works on text. For IS_PRE_SIGNED=true processors and for removed events there is nothing on disk to judge, so the gate is skipped and the processor runs on the path match alone.
  • No answer is not the same as no match. If the model server is down or times out, file_processor fails the run (decree tries it three times), rather than silently dropping the file — which would look exactly like a clean NO.

Handing off to an agent​

A processor's real job is usually to decide that something should happen, not to do it. agent_task is the machine that does it: it calls the hermes gateway and files the answer in workspace/ai/.

echo 'Read this note and work out what can be settled without the author.' \
| decree emit --machine agent_task \
--param file_path=notes/plan.md --param output_name=plan-followup

file_processor already lists agent_task and hermes_router in its emits:, so a processor may emit either. file-processors.example/agent-handoff.sh is a complete one.

Because hermes is an agent gateway that runs its own tool loop, the task inherits every MCP server hermes has registered: OpenViking search, Firecrawl web search, Playwright. The prompt does not name tools; it says what it wants, and hermes decides what to reach for. The gateway is called directly rather than through OpenCode — why.

agent_task needs HERMES_API_KEY (passed through once hermes is enabled), curl and jq, and /workspace/ai mounted into the decree container.

Triggering on workspace edits​

Use a file handler: it sees every change in the workspace, from any writer, and .gitignore keeps a folder out of it. With Nextcloud in the stack the workspace is also its workspace folder, so an edit in the desktop client is an upload and fires here too.

workspace/ai/ is where agent_task writes. It is written straight to disk, so it never fires a Nextcloud event; a handler whose pattern reaches ai/ does see it.

Hermes asking decree for work​

Hermes (and anything else confined to workspace/) has no mount into automation/ and shouldn't get one. Its way in is HTTP: automation-webhook serves /hermes/task/{profile} (an agent_task run on that profile, the body as the prompt) and /hermes/notify/{title}, on a secret of hermes' own. Each path fixes its machine, so those two are all hermes can ask decree for. workspace/ai/decree/ (kept current by routines_snapshot) lists every machine, its params and these endpoints.