TaskSultan Docs tasksultan.com

Capabilities

Files

Read, write and move files through ctx.files, with every path resolved inside the data-directory jail the Robot supplies.

ctx.files is how an automation reads and writes files. It never imports node:fs itself. The facade is the platform's file surface, so the jail, the tracing and the binary cap stay a platform concern behind a small API (framework/file-types.ts).

#The jail

Every path an automation passes is relative to the Robot's data directory. That keeps an automation portable: the same source runs under whatever TS_DATA_DIR the Robot was given, with no machine path baked in.

Absolute paths, drive letters, .. traversal and symlink escapes are all refused with FILES_PATH_REJECTED, a SystemException with retryable: false. The check normalises Windows separators first, so ..\x is rejected on every operating system rather than only on Windows. The one deliberate exception is exists: a probe reads an escape attempt as absent, so a guard clause like ctx.files.exists('../secret.txt') is false rather than throwing.

#Reading and writing text

The facade carries twelve verbs (framework/file-types.ts):

  • exists(file) returns a boolean.
  • readFile(file) reads utf-8 text.
  • writeFile(file, data) and appendFile(file, data) write utf-8; parent directories are created.
  • list(dir?) lists the direct children of a directory, defaulting to the data root.
  • deleteFile(file) removes a file and is a no-op when it is missing.
  • readJson(file) and writeJson(file, value) round-trip native JSON values.
  • readCsv(file) returns { headers, rows } with rows keyed by header; writeCsv(file, headers, rows) escapes commas, quotes and newlines.
  • readBinary(file) and writeBinary(file, data) handle bytes.

A ledger written by an automation looks like this:

import type { RunContext, Transaction } from '../framework';

const LEDGER = 'out/ledger.csv';
const HEADERS = ['CustomerId', 'Amount'];

async function record(ctx: RunContext, tx: Transaction): Promise<void> {
  const row = { CustomerId: tx.id, Amount: String(tx.payload.Amount ?? '') };
  if (!ctx.files.exists(LEDGER)) await ctx.files.appendFile(LEDGER, HEADERS.join(',') + '\n');
  await ctx.files.appendFile(LEDGER, [row.CustomerId, row.Amount].join(',') + '\n');
  ctx.log.info(`Appended ${tx.id}`);
}

async function readSeen(ctx: RunContext): Promise<Set<string>> {
  if (!ctx.files.exists(LEDGER)) return new Set();
  const { rows } = await ctx.files.readCsv(LEDGER);
  return new Set(rows.map((r) => String(r.CustomerId ?? '').trim()));
}

CSV values always come back as strings, because a CSV cell has no type. readJson keeps the JSON type, so a number stays a number.

#Reading and writing bytes

readBinary returns a Uint8Array, declared that way so no Node type leaks into an automation. Use it for an attachment, an archive, an image or a downloaded report. A text write would mangle any non-UTF-8 byte, which is why reading an attachment as text and re-encoding it corrupts the attachment.

Both binary verbs enforce a 32 MiB cap (MAX_BINARY_BYTES). On the read side the size is checked from stat before the read, because a cap enforced after the allocation prevents nothing. Exceeding it refuses with FILE_TOO_LARGE and writes nothing.

#What state it is in

The capability audit measured files as WORKING: a 256-byte binary written, read back and hashed, with the two hashes equal. The binary verbs were added later by the binary-file-api slice, which also gave the email attachment path byte-exact reads.

#Things that catch people out

There is no rename, move or copy verb. To move a file you read its bytes and write them to the new name, then delete the original.

Declare 'files'. A call to ctx.files in an automation whose capabilities array omits it throws FILES_NOT_DECLARED.

exists returns false for an escape attempt rather than throwing, so a path check is not a jail check. The jail rejects the read itself.

deleteFile is a no-op when the file is missing, so a run that deletes at the top does not need a guard.