Skip to content

Redac files

Store files and load apps.

The host provides one shared disk in apps.db. Initial mounts are /apps, /skills, and /references. A mount is a persistent row; the owner can add more top-level mounts. Files do not map to OS paths. The implementation uses the existing SQLite driver, with no AgentFS dependency.

Permission and identity

Declare the actions in AppDefinition v2:

{
  "actions": ["fs.read", "fs.write"]
}

This fragment is part of a complete manifest. Each mount has a user-owned connection: /apps uses fs:mount:apps, /skills uses fs:mount:skills, and /references uses fs:mount:references. Both actions default to Ask on each connection. Access can set Off, Ask, or Allow. The host binds your app identity; do not supply appId, connection, or an owner in file arguments. The host derives the connection from the validated path. A grant covers only that mount, including files another app wrote within it. There is no per-folder ACL. A read-only mount refuses changes even with Allow.

For example, the owner can allow an app to read /references, ask before writing /apps, and turn off /skills. These levels are independent.

A file read returns real content. Encryption protects the disk under the existing database key policy; it does not mask values returned to an approved app. Do not store secrets in a shared source or reference directory without this boundary in mind.

Search text

Use fs.search to find literal text in one directory and its descendants. It uses the selected mount's fs.read permission. Default matching ignores case; use caseSensitive: true when needed. Queries must have one line and at most 200 characters. Each matching line returns one hit, with a one-based line and UTF-16 column, a snippet, and the exact file revision.

const search = await cap.call('fs.search', {
  path: '/references/developers',
  query: 'apps.preview',
  limit: 20,
});
for (const hit of search.matches) {
  console.log(hit.path, hit.line, hit.snippet);
  // Use hit.revision as expectedRevision when reading that file.
}
if (!search.complete) console.log('Search limits prevented a full result.');

Search defaults to 20 hits and permits at most 100. It scans at most 256 files, 1 MiB of bytes, 512 directories, and 2,000 entries. Files above 256 KiB, invalid UTF-8, and files with NUL bytes are skipped. Results include scannedFiles, scannedBytes, skippedFiles, and complete. An incomplete result cannot prove that a query is absent from the subtree. Search refuses a changed subtree; repeat the search to use its current revision. Search does not cross mount boundaries or read OS files.

Create and edit files

A view uses client.cap; a script receives cap in its execution context. The same code below works with either bound capability client.

const cap = client.cap;
await cap.call('fs.mkdir', {
  path: '/apps/task-counter',
  requestKey: crypto.randomUUID(),
});
const created = await cap.call('fs.write', {
  path: '/apps/task-counter/view.js',
  content: 'export function mount(root) { root.textContent = "Hello"; }',
  expectedRevision: null,
  requestKey: crypto.randomUUID(),
});
if (created.state === 'conflict') throw new Error('The source file already exists.');

const current = await cap.call('fs.read', { path: '/apps/task-counter/view.js' });
const saved = await cap.call('fs.write', {
  path: current.entry.path,
  content: current.content + '\n// Updated source',
  expectedRevision: current.entry.revision,
  requestKey: crypto.randomUUID(),
});
if (saved.state === 'conflict') {
  // Read the current file and review the edit before a new write intent.
}

expectedRevision: null creates only when absent. An edit supplies the opaque revision from a read/stat. A changed file returns { state: 'conflict', entry }, with current metadata or null if absent. Revisions also change after deletion and recreation of the same path. Never guess a revision.

The request key identifies one intent. Persist the complete args and key if a reply is lost, then retry exactly those args. A changed payload needs a new key. Receipts are scoped to your bound app identity and survive restart. A receipt returns its original result; it is not a fresh stat.

Saving app source only stores bytes. Use the explicit app APIs below to review or load it. Pi discovers valid skill files on the next Harness turn when that profile can read the skills mount.

Review and load an app

Call apps.describe with {} to get the manifest schema, an index of the SDK contracts, instructions, and a working view starter. It returns static metadata and needs no mount action. sdkContractsJson lists the entry names by kind for each file in /references/sdk/; read that file with fs.read for the argument and result schemas of an entry. Each file is smaller than 50 KiB. A very large entry (for example docs.documents.create) keeps its shared $defs in a <group>.defs.json file, and each of its $defs values is { "$ref": "<file>#/<name>" }. Write a complete format version 2 manifest.json and its assets into a directory such as /apps/task-counter. JavaScript entry files must be ES modules. Use the app tutorial for scripts and collections.

const guide = await cap.call('apps.describe', {});
// Write guide.starter files into your chosen directory with fs.write.
const { entry } = await cap.call('fs.stat', { path: '/apps/task-counter' });
if (!entry) throw new Error('App source directory is missing.');
const source = { path: entry.path, expectedRevision: entry.revision };
const review = await cap.call('apps.preview', source);
// The owner inspects the source in Advanced › Developer › Apps from virtual files.
// When the owner requests loading, use the current directory revision:
const loaded = await cap.call('apps.load', source);

apps.preview needs fs.read on the source mount. It captures one revision in a SQLite transaction and creates a fixed source review. It does not execute code. The review shows in Advanced › Developer › Apps from virtual files. The owner can inspect the source, select Load app or Update app, or discard the review. Chat cannot open app pages before install. Check the manifest pages, permissions and schedules, and read the page code for faults before preparing the review. Give the owner a short list of what to check after install under the app's own Access settings. Source reviews expire after five minutes, and at most eight can exist.

Declare fs.loadApp if your app calls apps.load. This separate mount action defaults to Ask and authorizes reading and installing app source from that mount. It does not require a separate fs.read grant and does not grant the loaded app access to your data. Each loaded app gets its own permission ceiling and grants. Schedules start paused. Chat selects apps_describe, apps_preview, and apps_load through the same dispatcher and approval path.

Both source calls require the directory revision from fs.stat; an edit made while approval is pending causes a stale-source refusal. The owner can also turn on Advanced mode in Settings and enter a path in Advanced › Developer › Apps from virtual files › Review source. Loading from that review uses the exact reviewed bytes even if the VFS directory later changes. An identical installed package is a safe load retry. A changed package replaces the revision under the same app ID. Use apps.update for an explicit update with an installed-revision guard.

An app installed from a ZIP or the Marketplace has its source in /apps/<app-id>/. Edit it there and load it again to change the installed app.

Source capture allows at most 1024 nodes, 257 files including manifest.json, 1 MiB per file, and 16 MiB total. The manifest limit is 256 KiB. Text assets must be valid UTF-8. Supported assets are .js, .css, .png, .jpg, .webp, and .woff2. Missing entry files, invalid definitions, unsafe assets, and reserved IDs fail before execution. Do not place credentials in app source.

Update an installed app

Keep the manifest app ID. Write or restore the desired files, then capture their current directory revision. Use apps.preview to obtain the installed revision without changing it:

const { entry } = await client.cap.call('fs.stat', { path: '/apps/task-counter' });
if (!entry) throw new Error('Source is missing.');
const source = { path: entry.path, expectedRevision: entry.revision };
const review = await client.cap.call('apps.preview', source);
if (!review.installedRevision) throw new Error('Install this app first.');
// After the owner requests this version:
const app = await client.cap.call('apps.update', {
  ...source,
  appId: review.id,
  expectedInstalledRevision: review.installedRevision,
});

apps.update uses the same source-mount fs.loadApp grant as apps.load. The target must already be installed, and its manifest ID must match appId. Both directory and installed revisions must match at execution time, including after an Ask approval. A source edit or another update causes refusal. Review again before a new intent. An exact retry after a successful replacement can return a stale-installed refusal; read a new review to confirm the current revision before retrying. Older code is allowed through this same API. Chat calls apps_update with these arguments. Existing chats pin their tools; start a new chat after a host upgrade to use newly added tools.

The owner can also select Update app from the source review or Import ZIP review. Reviews pin both candidate bytes and the installed revision. An obsolete review cannot replace a later update. Records, Access and run history use one stable app identity. Old work and views stop; changed schedules pause. Existing collection schemas must remain unchanged. See package updates.

List, move, and remove

let cursor;
do {
  const page = await cap.call('fs.list', {
    path: '/references',
    limit: 50,
    ...(cursor ? { cursor } : {}),
  });
  for (const entry of page.entries) console.log(entry.path, entry.kind);
  cursor = page.nextCursor;
} while (cursor);

A list cursor pins the directory path and subtree revision. If the directory changes, restart the list. fs.stat returns { entry: null } for a missing path inside a permitted mount. SDK calls to / are refused; use paths within a mount. The owner lists mounts with vfs.mounts. Entries contain path, file/directory kind, revision, byte size, timestamps, and read-only state. There is no file subscription; refresh after changes.

const { entry } = await cap.call('fs.stat', { path: '/references/guide.md' });
if (entry) {
  const moved = await cap.call('fs.rename', {
    path: entry.path,
    to: '/references/manual.md',
    expectedRevision: entry.revision,
    requestKey: crypto.randomUUID(),
  });
  if (moved.state === 'ok') {
    await cap.call('fs.remove', {
      path: moved.entry.path,
      expectedRevision: moved.entry.revision,
      requestKey: crypto.randomUUID(),
    });
  }
}

Rename stays within one mount and refuses an existing destination. It moves a whole directory tree without copying file bytes. Remove refuses a nonempty directory unless recursive: true. Use the directory's latest revision for recursive removal; a child edit changes that revision. Mount roots cannot be changed or removed.

Byte transfers and limits

Paths are absolute, case-sensitive, Unicode NFC. Repeated slashes normalize to one separator. Traversal segments, backslashes, percent escapes, control characters, and URLs are refused. Limits are 64 segments, 255 UTF-8 bytes per name, and 4096 bytes per normalized path.

Read and write at most 64 KiB of decoded bytes per call. A file can contain at most 4 MiB; total file content can contain at most 256 MiB. These are content quotas, not a limit on total SQLite file size or metadata. Directory pages contain at most 100 entries. Quota failures return rate_limited; invalid paths/ranges return invalid_args.

fs.read defaults to UTF-8, offset 0, and length 65536. Offsets/lengths are bytes. An arbitrary slice can split a UTF-8 character and fail; use encoding: 'base64' for binary or large chunked transfers. Base64 must be canonical with padding. A read returns nextOffset, or null at the end. Pin expectedRevision on later read chunks to detect edits.

fs.write replaces the file by default (truncate: true, offset 0). To append/update a range, pass truncate: false, an explicit byte offset, and the previous revision. Gaps are zero-filled. Each chunk has its own request key and uses the revision returned by the prior chunk. Multi-chunk writes are not atomic as a whole. A phone's outer wire limit is smaller than a maximally escaped 64 KiB input; use smaller chunks, such as 16 KiB, with base64.

Pi skills and developer references

The host creates missing public defaults at startup and data reset:

  • /references/developers/: copies of all Markdown guides from docs/developers/.
  • /skills/develop-app/SKILL.md: instructions for creating, reviewing, and loading an app.
  • /references/sdk/: the SDK contracts as JSON files, made at startup from the contracts that the host serves. These files are an exception to the rules below: the host replaces a changed or deleted file at startup, so they always agree with the apps.describe index.

The packaged app contains a generated copy of this text; it does not need a repository checkout. Repeated startup preserves existing files and revisions, including owner edits. Default content does not replace an existing file after an app upgrade. Remove or update that file with normal VFS operations when you want new content. A deleted default returns at the next startup. A path conflict, read-only mount, or full disk skips the affected default without blocking startup. Seeding does not change Access grants.

To add a skill, save a file such as /skills/task-review/SKILL.md with this format:

---
name: task-review
description: Reviews saved tasks and proposes next steps. Use when the user asks to review tasks.
---

Read the app's task records through its permitted database API.
Explain the proposed changes before writing them.

Pi's skill parser reads the virtual namespace, with nested folders and .gitignore, .ignore, and .fdignore rules. The name must match the skill directory. Invalid metadata and oversized files are skipped. Discovery allows at most 1024 listed entries, 2048 operations, 64 KiB per text file, 1 MiB total text, and 64 distinct skill names. It has no OS files, shell, writes, or executable scripts.

A Harness profile selects the fs_read tool and its app declares fs.read. Set Access → app → /skills → Read virtual files → Allow for automatic skill discovery. Pi gets redacted skill resources and a description/path listing, refreshed for each new or resumed turn. Static host profile skills take precedence on name conflicts. The current permission is checked again before each request's listing. A skill grants no actions and does not change an app's permission ceiling.

Ask and Off do not expose automatic skill metadata. With Ask, Chat can read the known development skill through fs_read and ask for approval. It then reads needed files in /references/developers under that mount's separate read permission. Off refuses the read. The model reads the full skill only when the task needs it, and resolves relative references against the skill directory. Writing a skill cannot add a trusted host capability. Source: adapter, runtime resources, bundled skill.

Synced from the Redac Desktop developer guide on 2026-10-30 · SDK contract version 23