12. Input/Output
Learning the language? Hello World and console input are on Learn MALDA. This chapter is the full I/O how-to.
This chapter is the I/O how-to: console, files, paths, environment variables, host process facts, and capability tokens (permission objects so a tool cannot invent a path). Prefer the io.* namespace. Flat aliases such as print and readFile still run, but the editor warns malda-style. Signatures, git helpers, glob / grep, document extractors, and Spectre.Console live in 13. Built-in Functions.
File I/O is a host capability. The interpreter and C# transpile support it; the JavaScript backend does not (file-io is no on the backend capability matrix).
12.1 Console Output
Hello World is one call: io.print("Hello, World!"); — same file: Examples/Basics/hello_world.malda.
io.print(value) takes exactly one argument, converts it with string(value), and writes a line (a trailing newline). There is no println. Concatenate or interpolate before the call if you need several pieces on one line.
io.print("Hello, World!");
var name = "Alice";
io.print($"Hello, {name}!"); // interpolation — the $ is required
io.print("n is {n}"); // literal braces: a plain string does not interpolate
- Use
$"…"or"Value of x: " + string(x). A plain"n is {n}"prints the braces. - Numbers, booleans, arrays, and objects print their string form. For JSON text, call
toJSON(value)first. - Rich colour, tables, panels, and progress bars are
AnsiConsole.*— see 13.12 Spectre.Console.AnsiConsole.markupdoes not add a newline; usemarkupLinefor a standalone line. - When stdout is not a terminal, Spectre.Console strips ANSI and Unicode panel borders fall back to square corners. That is expected for pipes and redirected files.
12.2 Console Input
io.input(prompt?) writes an optional prompt (no extra newline) and reads one line from stdin. The result is always a string. Convert it if you need a number.
var name = io.input("Enter your name: ");
var age = int(io.input("Enter your age: "));
io.print($"Hello, {name}. Next year you will be {age + 1}.");
null. At EOF every later io.input also returns "". A loop that only advances on non-empty input never terminates. Treat blank as quit:
while (true) {
var raw = io.input("? ");
if (str.trim(raw) == "") {
break;
}
io.print(raw);
}
The call is asynchronous: execution pauses until a line arrives, then resumes at the same statement. It works inside loops, functions, and nested control structures.
For malformed numbers prefer toIntOr(raw, fallback) or toIntOrNull(raw) over bare int(...), which throws. See 13.1 Type Conversion.
12.3 Files
Paths are strings (or a matching capability token). Ordinary failures return null or false; they do not throw. Check the result. If a tool must not invent a path, do not pass a string — start with 12.6.
12.3.1 Read and write
io.writeFile(path, content) overwrites the file and returns true on success, false on I/O failure. Strings are written as UTF-8. Objects and arrays are serialized with toJSON first. io.readFile(path) returns the whole file as a string, or null when the path is missing or unreadable.
io.writeFile("_rm_io_notes.txt", "hello");
io.print(io.hasFile("_rm_io_notes.txt"));
io.print(io.readFile("_rm_io_notes.txt"));
io.deleteFile("_rm_io_notes.txt");
io.print(io.hasFile("_rm_io_notes.txt"));
io.writeFile("_rm_io_json.txt", dict { "ok": true });
var obj = parseJSON(io.readFile("_rm_io_json.txt"));
io.print(obj.ok);
io.deleteFile("_rm_io_json.txt");
io.print(io.readFile("_rm_io_missing.txt") == null);
12.3.2 Line ranges and line arrays
Line numbers are 1-based.
io.readFile(path, start)— from that line to the end. A negativestartmeans the last N lines (-30is the last 30).io.readFile(path, start, end)— inclusive range. A negativeendcounts from the end of the file.- If
startis past the last line, the result is""(empty string), notnull. io.readTextFileLines(path)— UTF-8 lines as an array, ornullif the file cannot be read. Prefer this overstr.split(io.readFile(path), "\n")for large files.
io.writeFile("_rm_io_lines.txt", "a\nb\nc\nd");
io.print(io.readFile("_rm_io_lines.txt", 2, 3));
io.deleteFile("_rm_io_lines.txt");
12.3.3 Directories, delete, and binary
| Call | Result |
|---|---|
io.hasFile(path) | true when a file exists (disk or embed:) |
io.hasDirectory(path) | true when a directory exists |
io.ensureDir(path) | Creates the directory and missing parents; no-op if it already exists. Throws on failure. Rejects embed: |
io.listDirectory(path) | Array of { name, type, path }. type is "file" or "directory". Missing directories yield []. Empty path means "." |
io.deleteFile(path) | true if the file was deleted or was already absent; false on an empty path or I/O failure |
io.copyFile(src, dest) | Binary-safe copy (overwrites dest). Returns true on success, false on I/O failure (missing source, missing parent directory). Rejects embed: source and dest. Does not create parent directories |
io.readFileBase64(path) / io.writeFileBase64(path, b64) | Raw bytes as base64. Read returns null when missing; write returns false on invalid base64 |
io.writePng(path, width, height, pixels) / io.readPng(path) | 8-bit RGBA. pixels is a flat array of length width * height * 4. Write returns false on I/O failure, rejects embed:, and throws above 16 megapixels. Read returns { width, height, pixels }, or null when the file is missing or not a decodable 8-bit non-interlaced PNG. Interpreter and C# transpile only |
var px = [255, 0, 0, 255];
var ok = io.writePng("_rm_io_px.png", 1, 1, px);
var img = io.readPng("_rm_io_px.png");
io.print(ok);
io.print(img.width);
io.print(img.pixels[0]);
io.print(img.pixels[3]);
io.deleteFile("_rm_io_px.png");
io.writeFile("_rm_io_list.txt", "x");
var items = io.listDirectory(".");
var found = false;
for (var item in items) {
if (item.name == "_rm_io_list.txt") {
found = true;
}
}
io.print(found);
io.deleteFile("_rm_io_list.txt");
insertAtLine, replaceInFile, and editFile edit text in place — catalog and failure style in 13.8. insertAtLine is interpreter-only.
Digital text from documents: pdf.extractText(path, password?) (PdfPig text layer, no OCR) and doc.extractText(path) (.docx only). Those do throw on a missing file. See 13.8.1.
12.3.4 Embedded folders (embed:)
Compile with malda compile … --embed-folder <dir[=alias]> to pack a directory into the executable. At runtime, read with the virtual scheme embed:<alias>/<relative>. Nothing is extracted to disk. Writes (io.writeFile, io.ensureDir, io.deleteFile, …) reject embed: paths. io.hasEmbeddedFolder(alias) / io.embeddedFolderRoot(alias) inspect what was packed. Full example in 13.8.0.
12.4 Paths
Join and inspect paths instead of concatenating slashes. io.pathJoin uses the host separator. io.pathNormalize returns an absolute path.
io.print(io.getFileName("docs/notes.md"));
io.print(io.pathGetExtension("docs/notes.md"));
io.print(io.isPathUnder("brain", "brain/notes/a.md"));
io.getDirectoryName(path)— parent directory, or""for a bare filename.io.pathExists(path)— file or directory.io.isPathUnder(root, path)—truewhenpathresolves torootor a descendant. Uses a directory-separator boundary (demodoes not matchdemo-evil). Works for disk paths andembed:roots. Traversal such asbrain/../secret.txtisfalse.getProgramDirectory()— folder of the running.maldaunder the interpreter, or the compiled executable’s directory aftermalda compile. Not on theioobject.
12.5 Environment and Host
These read the process environment and the machine the program is running on. They are not file I/O, but they are how programs find keys, args, and a place to load sidecar files.
io.print(io.getEnvOr("MALDA_RM_IO_MISSING_VAR_7D05", "fallback"));
var host = getHostPlatform();
io.print(str.length(host.pathSeparator));
io.getEnv(name)— string, ornullwhen unset. Do not pass thatnulltostr.trim; useio.getEnvOr(name, default?)(empty string when the default is omitted) orstr.trimText.io.hasEnv(name)— whether the variable is set.getHostPlatform()—{ os, arch, pathSeparator, description }. No arguments.getCommandLineArgs()— array of strings passed to the program, excluding the program name. Same in interpreted and transpiled runs.
Assistant config under ~/.malda (getMaldaHome, getMaldaConfig) is documented with the personal assistant.
12.6 Capability Tokens
A capability token is a permission object that your program (the host) creates. Treat it as a key to one file, HTTP origin, MCP tool, or argv prefix: whoever holds the key can use that permission. Nobody can make a working key by writing down a description of the lock.
That is the whole idea. The rest of this section is the why, the API, and the pattern you should copy for tools.
/etc/passwd, any host, or any argv. A token binds the permission to a value you chose, before the model runs. Signature catalog: 13.19.3. First-contact scaffold: malda new agent.
12.6.1 Why @effects("io") is not enough
@effects("io") is a name allow-list for the type checker. It says “this function may do I/O”. It does not say which files. A tool that takes path: string can still read anything the model puts in that string:
@effects("io")
function readNote(path) {
return io.readFile(path); // the model can pass "/etc/passwd"
}
A token closes that hole. The host creates the token for a path it trusts. The tool consumes the token. A string or a look-alike object is rejected.
12.6.2 Three different words “capability”
The manual uses “capability” in three places. They are not the same thing:
| Phrase | What it means | Where |
|---|---|---|
cap.fileRead / capability token | An unforgeable permission object for one path, URL prefix, MCP tool, or argv prefix | This section |
| Backend capability matrix | Which backends implement a product feature (file I/O, agents, DOM, …) | backend-capability-matrix.md |
@requires("file-io") | A property test that skips backends that lack that feature | 36. Property Testing |
12.6.3 Kinds
A token has readable fields kind and path (plus name for an MCP tool). You cannot assign to them. There is no flat cap() or fileRead() alias — always write cap.….
| Mint | kind | Consume with |
|---|---|---|
cap.fileRead(path) | fileRead | cap.read(token) or io.readFile(token) |
cap.fileWrite(path) | fileWrite | cap.write(token, content) or io.writeFile(token, content) |
cap.dirList(path) | dirList | cap.list(token) or io.listDirectory(token) |
cap.httpGet(origin) | httpGet | cap.fetch(token, urlOrPath?) or webFetch(token) |
cap.mcpCall(server, tool?) | mcpCall | cap.invoke(token, server, args?) or callTool(token) |
cap.shell(prefix) | shell | cap.run(token, extraArgs?) or runCommand(token) |
A fileWrite token is not a read token. Passing the wrong kind throws. cap.confine joins a relative file path, HTTP path, MCP tool name, or extra argv — escapes throw before consume.
var http = cap.httpGet("https://example.com/docs");
io.print(http.kind);
io.print(http.path);
io.print(cap.confine(http, "guide").path);
var escaped = false;
try {
cap.fetch(http, "https://evil.com");
} catch (e) {
escaped = true;
}
io.print(escaped);
io.print(cap.mcpCall("local", "add").name);
io.print(cap.confine(cap.shell("git"), "status").path);
12.6.4 Mint, inspect, consume
Mint means “create a real token in the host”. The result is a sealed host object, not a dict. cap.is is the only safe type test — typeOf will not tell you “this is a capability”.
var notes = cap.fileRead("notes.md");
io.print(notes.kind);
io.print(cap.is(notes, "fileRead"));
io.print(cap.is(notes, "fileWrite"));
io.print(cap.is({ "kind": "fileRead", "path": "notes.md" }));
Consume the matching kind only. Strings and { kind, path } dicts throw — including after toJSON / parseJSON, because JSON cannot rehydrate a token.
var path = "_rm_cap_rw.txt";
cap.write(cap.fileWrite(path), "hello from a token");
io.print(cap.read(cap.fileRead(path)));
var forged = false;
try {
cap.read({ "kind": "fileRead", "path": path });
} catch (e) {
forged = true;
}
io.print(forged);
var asString = false;
try {
cap.read(path);
} catch (e) {
asString = true;
}
io.print(asString);
io.deleteFile(path);
cap.read(token, startLine?, endLine?)forwards the optional line range toio.readFile(1-based; see 12.3.2).- Ordinary string paths still work on
io.readFile/io.writeFile/io.listDirectoryfor code that is not a tool. - Ordinary I/O failures still return
nullorfalse. A bad token (string, dict, wrong kind) throws.
12.6.5 Narrow a token with cap.confine
cap.confine(token, relativePath) returns a new token of the same kind whose path is the parent path plus the relative path. Use it to hand a tool a workspace root, then lock each call down to one child.
.., an absolute path, or anything else that would escape the parent throws. The host never “fixes” the path for you.
io.writeFile("_rm_cap_welcome.txt", "welcome");
var root = cap.fileRead(".");
var child = cap.confine(root, "_rm_cap_welcome.txt");
io.print(str.trim(cap.read(child)));
var escaped = false;
try {
cap.confine(root, "../secret.txt");
} catch (e) {
escaped = true;
}
io.print(escaped);
io.deleteFile("_rm_cap_welcome.txt");
12.6.6 Pattern: the host mints, the model passes a relative path
Do not send a token through LLM JSON. A token cannot survive toJSON. The model only ever sees a relative path string. The host keeps the root token in its own memory and confines each call:
io.writeFile("_rm_cap_note.txt", "welcome");
var notesRoot = cap.fileRead(".");
function readNoteWith(rootCap, relativePath) {
var child = cap.confine(rootCap, relativePath);
return cap.read(child);
}
io.print(str.trim(readNoteWith(notesRoot, "_rm_cap_note.txt")));
var escaped = false;
try {
readNoteWith(notesRoot, "../secret.txt");
} catch (e) {
escaped = true;
}
io.print(escaped);
io.deleteFile("_rm_cap_note.txt");
On a live agent, wrap that helper with @Tool, validate the args with a schema, and mark the handler @effects("cap"). The model’s tool argument is relativePath only. Full walk-through: 19.8. Offline starter: malda new agent (host-minted notes/).
12.6.7 Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Give the tool a path: string and rely on @effects("io") | The model can invent any path. The decorator only names the effect. | Host-mint a root token; take a relative path; cap.confine |
Pass the token through LLM JSON / toJSON | The other side gets a dict. cap.read throws. | Keep the token in host memory. Send only a relative path. |
Build { "kind": "fileRead", "path": "…" } by hand | Looks like a token. cap.is is false. Consume throws. | Call cap.fileRead / httpGet / mcpCall / shell |
Call cap.read("notes.md") | Throws. Consume APIs reject strings. | cap.read(cap.fileRead("notes.md")), or io.readFile("notes.md") in non-tool code |
Pass a fileWrite token to cap.read | Throws (wrong kind). | Mint the kind you will consume |
Treat createReadFileTool(workingDir) as a token | The factory only checks that paths stay under a string directory. It is not unforgeable. | Fine for built-in file tools. Custom @Tool file handlers should use cap.* |
12.6.8 Backends and @effects("cap")
Mark handlers that mint or consume tokens @effects("cap") (or list both "io" and "cap"). @pure() functions cannot mint or consume tokens under --strict-types.
The interpreter and C# transpile implement the full surface. JavaScript can mint, cap.is, and cap.confine; consume (cap.read / write / list / fetch / invoke / run) is host-only — same as other file I/O and agents on the backend capability matrix.
Worked examples: Examples/Tools/capability_tokens.malda, Examples/Tools/capability_http_mcp_shell.malda. Agent-shaped few-shots: docs/llm/few-shot/26_tool_cap_read.malda, docs/llm/few-shot/42_cap_http_mcp_shell.malda.
12.7 Searching Files
io.glob(pattern, dirPath?) finds paths. io.grep(pattern, filePath) searches contents. Both take a required first argument and then optional positional arguments. On a missing path or a bad regex they return an error string, not a collection — guard with typeOf(result) == "string".
io.writeFile("_rm_io_glob.txt", "needle");
var found = io.glob("_rm_io_glob.txt", ".");
io.print(found.count);
io.deleteFile("_rm_io_glob.txt");
Default glob cap is 200 results (hard cap 500); the result is { items, count, truncated } with each item { name, type, path }. grep defaults to recursive search with line numbers. Recursive search does not enter directory junctions or symbolic links, so a link that points back at a parent cannot loop. Argument lists and the count-only shape are in 13.8.2.
12.8 Sleep
sleep(milliseconds) pauses without spinning the CPU. The argument must be a non-negative integer. It is a built-in, not an io.* member. Useful for keep-alive loops around a server.
sleep(0);
io.print("awake");
while (server.isRunning) {
sleep(1000);
}
sleep, io.writeFile, and other deny-listed effects raise WF1001 / WF1002 when they run in a deterministic workflow section — including when nested in a helper. Put clock, sleep, and I/O inside a step. See 23. Durable Workflows.
12.9 Fetching URLs
webFetch(url, maxBytes?, timeoutMs?) fetches an http:// or https:// URL using the same host path as httpGet. Other schemes (including file://), empty URLs, and URLs without a host are rejected. Optional maxBytes defaults to 100000 (hard cap 500000); longer bodies are truncated and truncated is set. Optional timeoutMs defaults to 15000 (hard cap 60000). Returns { ok, status, url, content, truncated, error? }. content is always a string: a text body is used as-is; a JSON object or array body is JSON-stringified. The agent factory is createWebFetchTool (no working directory; parallel-safe).
var page = webFetch("https://example.com/");
print(page.ok);
print(page.status);
print(page.truncated);
For JSON APIs with headers, auth, and retries prefer 30. REST Web Client. A capability token uses cap.httpGet + cap.fetch / webFetch(token) — see 12.6. Name index: 13.5.4.
12.10 What This Chapter Is Not
| Need | Go to |
|---|---|
| HTTP client / REST calls | 30. REST Web Client (webFetch is 12.9) |
HTTP / HTML / ui.* servers | 25. Web UI Overview |
| SQL clients | 17. Database Support |
| Shell commands | runCommand in 13.10 (blocked dangerous names, 1-hour timeout cap) |
| Git | 13.9 |
| Agent file tools | createReadFileTool and siblings in 19. Agent Orchestration |
| Bind a custom tool to one folder | 12.6 Capability tokens and 19.8 |
See Also
- 7. Expressions — string interpolation
- 13. Built-in Functions — I/O catalog, Spectre.Console, git,
runCommand - 9. Functions —
@pure/@effects("io")/@effects("cap") - 19.8 Custom tools — host-mint a root token, pass a relative path
- 23. Durable Workflows — I/O must live inside
step Examples/Tools/file_operations.malda,Examples/Tools/capability_tokens.malda