Reading and Writing Files
Read a file into a flow and write one back out, confined to a directory you name.
The file connector gives a flow the local filesystem. It owns a directory, and the file-read and file-write blocks work inside it.
This guide builds samples/file-io, which reads a YAML service config, reshapes it, and writes the deployment settings out as a .env file.
Declare the root
Give the connector the directory the flow is allowed to touch:
env:
- name: FILE_ROOT
default: samples/file-io/workspace
connectors:
- name: workspace
type: file
settings:
root: ${FILE_ROOT}
createDirs: trueThere is no default root. Every path the blocks resolve is relative to it, and a path that tries to leave (../../etc/passwd, or a symlink pointing outside) is refused. Set createDirs when the flow writes into subdirectories that may not exist yet. A root that is missing, or names a file rather than a directory, fails at startup rather than on the first message.
Read a file
Point file-read at a path, written as an expression so the flow chooses the file per message:
- type: file-read
name: read-source
settings:
connector: workspace
path: body.sourceThe body is now the raw-content envelope {contentType, rawData}: the file's bytes with the MIME type its extension names. Nothing has been parsed yet, so the same block reads YAML, CSV, Markdown or anything else.
Parse it with the matching CEL function:
- type: set-payload
name: parse-source
settings:
value: fromYaml(body.rawData)fromJson, fromEnv and fromFormData work the same way; see Octo Extensions. Set resultVar when the file is one input among several and you want the body left alone.
Write one back
Render the contents with a to* function and hand them to file-write:
- type: file-write
name: write-env
settings:
connector: workspace
path: '"out/" + body.name + ".env"'
content: 'toEnv({"SERVICE_NAME": body.name, "REPLICAS": body.replicas})'
resultVar: writtenThe block passes the message through unchanged, so the body is still the parsed config and the {path, bytes} receipt lands in vars.written. Omit content to write the body itself: its raw content as-is, or its JSON encoding.
Run it:
bin/octo invoke --config samples/file-io/config.yaml --flow render-config \
--data '{"source": "service.yaml"}'Hand it to an agent
Because the path is an expression, a flow like this is a tool an agent can call with a file name it picked, and the root keeps that choice inside a directory you named. Reference the flow from an ai-agent tool list.
Point root at a directory holding only what the flow should reach, and treat an incoming path as untrusted. The connector refuses escapes, but it cannot know that a file inside the root was one you meant to expose.
Move a binary file
Set encoding: base64 on both blocks: file-read base64-encodes the bytes and file-write decodes them, so the file survives byte for byte:
- type: file-read
settings:
connector: workspace
path: body.source
encoding: base64Use it whenever the file might not be text. rawData is a UTF-8 string, so with encoding: text bytes that are not valid UTF-8 survive within one process but are silently replaced the first time the message crosses a JSON boundary (a queue hop, invoke, or a trace).
Test it
The sample reads its own output back, so its assertions see what reached the disk:
- type: file-read
name: read-back
settings:
connector: workspace
path: vars.target
resultVar: rereadbin/dolphin test samples/file-io/config_test.yamlIts suite also covers two failures: a path escaping the root, and a file that is not there. Both fail the message, so a flow's error path can handle them.