Files & Mirrors
Files in Portuni live in two places at once: the remote (the source of
truth for the team) and the local mirror on each device. The metadata
row in files binds a node to a remote location; the path on the current
device is derived from the per-device mirror root, the file’s remote_path,
and the node’s sync_key. There is no persisted local_path column on
files – it would go stale across devices and renames.
portuni_mirror
Section titled “portuni_mirror”Create a local folder for a node on this device and register it.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | yes | Node ID |
targets |
string[] | yes | Mirror targets (only "local" supported in Phase 1) |
custom_path |
string | no | Override default path |
Default path: {PORTUNI_WORKSPACE_ROOT}/{org-slug}/{type-plural}/{node-sync-key}/
(organizations mirror directly to {PORTUNI_WORKSPACE_ROOT}/{org-slug}/).
Locally, every mirror gets the outputs/, wip/, resources/
subdirectories. The org-plural subfolders (projects/, processes/,
areas/, principles/) are scaffolded on the remote when an
organization is mirrored; locally they appear only as parent directories
once child nodes are mirrored.
Returns: { node_id, local_path, subdirs, remote_scaffold, scope_config } —
remote_scaffold lists the remote folders created and the resolved
remote_name; scope_config lists the per-mirror config files written.
Mirror registrations are per device. Each machine keeps its own copy
of the registry in {PORTUNI_WORKSPACE_ROOT}/.portuni/sync.db; the shared
Turso DB does NOT store per-device paths.
portuni_store
Section titled “portuni_store”Copy a file into the node’s local mirror, upload it via the routed remote,
and persist a files row + file_state cache.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | yes | Node ID |
local_path |
string | yes | Absolute path of the source file on this device |
status |
enum | no | wip (default) or output |
subpath |
string | no | Optional subfolder within the section |
The file is copied to {mirror}/wip/... or {mirror}/outputs/... based
on status (or detected from the source path if it already lives inside
the mirror), then uploaded to the remote at
{org-sync-key}/{type-plural}/{node-sync-key}/{section}/{subpath}/{filename}.
The remote is resolved through remote_routing (priority-ordered).
The sync_key-anchored path means renaming a node does NOT change the
remote location, so existing references stay valid.
Returns: { file_id, remote_name, remote_path, local_path, hash }
portuni_pull
Section titled “portuni_pull”Two modes:
file_id– download the remote version into the mirror, refresh the local hash cache. Used to restore a deleted local copy or pull a teammate’s update.node_id– preview only. Classifies each file asunchanged | updated | conflict | remote_missing | remote_error | nativewithout modifying anything. Use this before pulling to see what would change.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_id |
string | one of | File ID (download mode) |
node_id |
string | one of | Node ID (preview mode) |
force |
boolean | no | Download mode only. Overwrite the local file even when it has unpushed local changes. Default false |
portuni_list_files
Section titled “portuni_list_files”List files across all nodes with optional filtering. Each row includes a
derived local_path (from the current mirror + remote_path +
sync_key); it is null when the node has no mirror on this device.
local_path is the node’s real mirror for the home node. For any
other in-scope node with a local mirror on this device — including a
depth-1 neighbour — it is that node’s session-local hardlink projection
directory instead (preferred over the real mirror even for a depth-1
neighbour, see disk read scope). A node
with no local mirror on this device has local_path: null either way —
read their content with portuni_read_file (below).
Scope gating: with node_id the node must be in session scope (out of
scope returns scope_expansion_required). Without node_id results are
restricted to the current session scope set (empty scope returns an
empty array) — no confirmation needed, it is not a broad query. The same
gating applies to portuni_list_events. portuni_search_files and
portuni_list_nodes(scope: "global") are the exception — see below and
scope enforcement.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | no | Filter by node |
status |
enum | no | Filter by status (wip or output) |
limit |
number | no | Max rows, newest first (default 500, max 2000) |
Returns: Array of files, each with: id, node_id, node_name,
filename, status, remote_name, remote_path,
current_remote_hash, last_pushed_at, is_native_format, the derived
local_path, and updated_at.
portuni_read_file
Section titled “portuni_read_file”Read a file’s content from an in-scope node the seatbelt does not expose on
its real mirror path — an ad-hoc node reached by deeper graph traversal
(beyond the home node and its depth-1 neighbours, whose folders you read
natively via the local_path returned by portuni_get_context /
portuni_get_node). Such a node, if it has a local mirror on this device,
is also readable natively at its hardlink projection directory (same
local_path field, created on first touch — see disk read
scope); portuni_read_file is the channel
that works regardless, since it has no dependency on a local mirror at all.
The server reads the live file from the node’s local mirror when one
exists — no stale copy — and otherwise, when the serving machine holds
no mirror of the node (the central server, or a remote client such as
Claude Desktop against https://…/mcp with a device token, with no local
workspace), reads straight from the node’s routed remote (Google Drive),
the same path GET /nodes/:id/file takes. In agent mode the sidecar reads
its own mirror first and proxies the call to central when it has none.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | yes | Node the file belongs to |
path |
string | yes | File path within the node, e.g. wip/notes.md |
as_path |
boolean | no | Return { path, bytes, mime } (a disk path) instead of inline content, even under the 1 MB limit — useful for PDFs/binaries or when you’d rather Read/Grep the file natively. |
Returns the file content as UTF-8 text, or [binary file, N bytes, base64]
followed by base64 for non-text files, up to the 1 MB inline limit. A file
over that limit — or any call with as_path: true — is instead spilled to
a disk path: { path, bytes, mime }, a location inside this session’s disk
projection (see disk read scope) that your
own Read/Grep tools can use directly. There is no chunked-read parameter
(offset/length) — read the returned path yourself. Scope-gated exactly
like portuni_get_node: reading a node not yet in scope returns
scope_expansion_required (call portuni_expand_scope first). Errors when
the file does not exist, the file is a native Google format (Doc/Sheet/Slides
— no byte content), or the node has neither a mirror on this device nor a
routed remote.
portuni_search_files
Section titled “portuni_search_files”Search file contents across Portuni-tracked files. The search runs on
the configured remote(s) — Google Drive’s fullText contains (which indexes
Docs, PDFs and text files; whole words and phrases, not substrings or regex)
or a text grep on an fs remote — and each hit is joined back onto the
files registry, so a loose Drive object Portuni never registered is never
returned. Search is discovery, not ingestion: it is permission-only in
every session type — no scope gate, with or without node_id. Results
are limited only to nodes the caller can see (group visibility). Each hit
carries a short, length-capped snippet, not the full file; read a hit in
full with portuni_read_file, which follows the normal scope-expansion
rules. See scope enforcement.
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | yes | Words or a phrase to find in file contents |
node_id |
string | no | Restrict to one node |
limit |
number | no | Max hits (default 20, max 50) |
Returns: Array of hits, each with file_id, node_id, node_name,
node_type, filename, path (the node-relative path, e.g.
wip/notes.md), mime_type, and when the remote provides them
modified_at and snippet (Drive returns no snippet). Open a hit with
portuni_read_file(node_id, path).
portuni_status
Section titled “portuni_status”Scan tracked files and (optionally) discover new local / new remote files. Call this at session end when files were touched, before major migrations, or whenever the user asks about sync state.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | no | Restrict to one node |
remote_name |
string | no | Restrict to one remote |
include_discovery |
boolean | no | Walk the mirror + list the remote for new files (default: true). With node_id, the remote is listed even when the node has no mirror on this device (a connector session on the central server, for instance), so a file dropped on the remote by hand or via a Drive connector surfaces as new_remote and can be portuni_adopt_files’d; new_local needs a mirror |
classes |
string[] | no | Only return entries for these classes: clean, push, pull, conflict, remote_missing, remote_error, native, deleted_local, new_local, new_remote |
path_prefix |
string | no | Only return entries whose path starts with this prefix |
limit |
number | no | Max entries per class |
offset |
number | no | Skip this many entries per class first |
Returns: classified buckets (clean, push_candidates, pull_candidates,
conflicts, remote_missing, remote_error, native, new_local,
new_remote, deleted_local, deleted_remote), plus counts (the true
size of every bucket, even ones excluded by classes or thinned by
path_prefix/limit/offset) and truncated (true when any bucket had
entries left out). On a large node, classes/path_prefix/limit/
offset keep the response from blowing past the MCP response size limit
while counts still answers “how much is left” in one call. There is no
moved bucket — an on-disk move is paired as it happens (see below), not
reported as a scan finding.
portuni_list_remotes / portuni_setup_remote / portuni_set_routing_policy
Section titled “portuni_list_remotes / portuni_setup_remote / portuni_set_routing_policy”Manage the pluggable remote backends and the priority-ordered routing
rules that pick a remote for each (node_type, org_slug) combination.
See concepts/mirrors for the per-device mirror model.