Mountable

Python and TypeScript SDKs

Read, write, list and follow a filesystem's files from a Python or TypeScript backend, and create mount tickets for sandboxes.

The SDKs wrap the files API and mount tickets. Files they write are read by mounts and the other way round. Both SDKs behave the same way. Python uses snake_case and is synchronous; TypeScript uses camelCase and returns promises.

The SDKs work on files and mount tickets only. Manage filesystems, grants and keys, and list or revoke sessions, with the CLI, the MCP server, the console or the API.

Install

Python 3.10 or newer, synchronous, with one dependency (httpx):

pip install mountable-sdk

TypeScript and JavaScript: an ES module with type declarations for Node 18 or newer, Bun and Deno. It uses the platform fetch and has no runtime dependency:

npm install mountable-sdk

On Deno, run with --allow-env or pass apiKey, so the SDK can read the key.

Configure

The client reads the API key from MOUNTABLE_API_KEY. MOUNTABLE_API_URL changes the address, which is https://mountable.io by default. Passing api_key / apiKey or base_url / baseUrl overrides both. Without a key, creating the client fails with unauthenticated.

For a backend that works on one filesystem, create a key limited to that filesystem on the console's API keys page; it gets its grant at once. Open the handle in a mode the key's grant allows: filesystem() defaults to "rw", so a read-only key needs "ro". Keep the key on your server. The SDKs are for backends, not browsers.

Read and write files

Python

from mountable_sdk import Mountable, MountableError
 
client = Mountable()
fs = client.filesystem("fs_…", mode="rw")  # sends nothing until first used
 
info = fs.write("tickets/AB-42.md", "# AB-42\n")
file = fs.read("tickets/AB-42.md")  # file.data (bytes), file.info (FileInfo)
page = fs.list("tickets")  # page.entries, page.next (pass it as after=)

TypeScript

import { Mountable, MountableError } from "mountable-sdk";
 
const client = new Mountable();
const fs = client.filesystem("fs_…", "rw"); // sends nothing until first used
 
const info = await fs.write("tickets/AB-42.md", "# AB-42\n");
const file = await fs.read("tickets/AB-42.md"); // file.data (Uint8Array), file.info (FileInfo)
const page = await fs.list("tickets"); // page.entries, page.next (pass it as after)

A filesystem handle creates a one-hour token session on its first call and keeps the token in memory only. A session within 60 seconds of expiring is replaced before the next call, and a 401 creates a new session and sends the call once more, so you never handle tokens yourself. Each handle has its own session: reuse one handle per filesystem rather than opening one per request. A handle opened with "ro" reads, lists and follows changes but cannot write.

read and write hold the whole file in memory. For very large files, mount the filesystem instead.

In Python, Mountable holds a connection pool: call client.close() when you are done, or use with Mountable() as client:.

Operations

PythonTypeScriptDoes
Mountable(api_key=None, base_url=None)new Mountable({ apiKey, baseUrl })the client; both default to the environment
client.filesystem(id, mode="rw")client.filesystem(id, "rw")a handle on one filesystem, "rw" or "ro"
fs.write(path, data, *, if_match=None, if_none_match=False)fs.write(path, data, { ifMatch, ifNoneMatch })create or replace the whole file from bytes or a UTF-8 string; returns its FileInfo
fs.read(path)fs.read(path)the file: .data and .info
fs.delete(path, *, if_match=None)fs.delete(path, { ifMatch })delete a file, a symbolic link or an empty directory
fs.list(path="", *, after=None, limit=None)fs.list(path, { after, limit })one directory page: .entries and .next
fs.changes(cursor=None, wait=25)fs.changes({ cursor, wait })changes after a cursor, waiting up to wait seconds (0–25): .events and .cursor; without a cursor, at once, no events and the current cursor
client.create_mount_ticket(fs_id, mode="rw", max_duration_seconds=None, idempotency_key=None)client.createMountTicket(fsId, { mode, maxDurationSeconds, idempotencyKey })a one-time ticket for a sandbox to mount with

A FileInfo has path, type (file, directory or symlink), size, etag (files only) and modified_at / modifiedAt as a date. Paths are relative to the filesystem root, and leading and trailing / are ignored. The SDK percent-encodes each name for you and refuses a . or .. name with bad_path before sending anything. The rules for paths, sizes and quotas are those of the files API.

Write without losing changes

Pass the ETag you last saw to write or delete only if nobody changed the file meanwhile. if_none_match=True / ifNoneMatch: true creates the file only if nothing exists at the path. A failed condition raises precondition_failed with the file's current ETag, or none when nothing exists:

Python

try:
    fs.write("tickets/AB-42.md", "# AB-42, edited\n", if_match=info.etag)
except MountableError as e:
    if e.code != "precondition_failed":
        raise
    print("changed meanwhile; now", e.current_etag)

TypeScript

try {
  await fs.write("tickets/AB-42.md", "# AB-42, edited\n", { ifMatch: info.etag });
} catch (e) {
  if (!(e instanceof MountableError) || e.code !== "precondition_failed") throw e;
  console.log("changed meanwhile; now", e.currentEtag);
}

The check and the write happen in one step for every other writer, mounts included. See conditional writes.

Follow changes

To stay in sync without gaps, take a cursor first, then list or read what you need, then follow changes from that cursor. When the history behind a cursor is gone, changes raises cursor_expired: start again the same way.

Python

def resync() -> str:
    cursor = fs.changes().cursor  # 1. a cursor first
    after = None
    while True:  # 2. then list, every page
        page = fs.list("tickets", after=after)
        for entry in page.entries:
            print(entry.path, entry.etag)
        if page.next is None:
            return cursor
        after = page.next
 
 
cursor = resync()
while True:  # 3. then follow changes from the cursor
    try:
        batch = fs.changes(cursor=cursor, wait=25)
    except MountableError as e:
        if e.code != "cursor_expired":
            raise
        cursor = resync()
        continue
    for event in batch.events:  # reconcile each path to event.file
        print(event.type, event.path, event.file and event.file.etag)
    cursor = batch.cursor

TypeScript

async function resync(): Promise<string> {
  const { cursor } = await fs.changes(); // 1. a cursor first
  let after: string | undefined;
  for (;;) {
    // 2. then list, every page
    const page = await fs.list("tickets", { after });
    for (const entry of page.entries) console.log(entry.path, entry.etag);
    if (page.next === null) return cursor;
    after = page.next;
  }
}
 
let cursor = await resync();
for (;;) {
  // 3. then follow changes from the cursor
  let batch;
  try {
    batch = await fs.changes({ cursor, wait: 25 });
  } catch (e) {
    if (!(e instanceof MountableError) || e.code !== "cursor_expired") throw e;
    cursor = await resync();
    continue;
  }
  for (const event of batch.events) console.log(event.type, event.path, event.file?.etag); // reconcile each path
  cursor = batch.cursor;
}

Each event is created, updated or deleted, with the path's current file, or none when nothing exists there any more. Treat events as invalidations, not a history; see following changes.

Hand a sandbox a mount ticket

Your backend creates the ticket with its API key. The sandbox mounts with the ticket only, so the key never enters the sandbox:

Python

ticket = client.create_mount_ticket(
    "fs_…", mode="ro", max_duration_seconds=12 * 3600, idempotency_key="job-42"
)
# pass ticket.ticket to the sandbox on stdin:
#   mountable mount --ticket-stdin /mnt/mountable

TypeScript

const ticket = await client.createMountTicket("fs_…", {
  mode: "ro",
  maxDurationSeconds: 12 * 3600,
  idempotencyKey: "job-42",
});
// pass ticket.ticket to the sandbox on stdin:
//   mountable mount --ticket-stdin /mnt/mountable

The result has the session's id, filesystem_id / filesystemId, mode, state, expires_at / expiresAt and ticket_expires_at / ticketExpiresAt. The ticket itself is returned once, in .ticket, and is left out when the result is printed or inspected. It is single-use and must be exchanged within five minutes. Keep the id to revoke the session later. The session lasts 12 hours unless you pass max_duration_seconds: at least 60 seconds, at most 365 days, and never past the key's expiry.

create_mount_ticket is sent once and never retried by the SDK. Pass your own idempotency key: without one the SDK picks a random key you cannot reuse. After an unknown outcome, retry with the same key. A replay returns the same session with no ticket; check its state:

  • requested: the ticket was lost. Revoke that session and create a new one with a fresh key.
  • active or later: the ticket was used. Keep the working mount, and use a new key for another mount.
  • 409 idempotency_conflict: a concurrent request with the same key created the session first; retry with the same key to get its replay.

These are the retry rules the CLI follows for you. The full flow, from the sandbox side to revoking, is in Mount from a sandbox.

Errors and retries

Every failure raises (Python) or rejects with (TypeScript) MountableError:

FieldHolds
statusthe HTTP status, or none when no answer arrived (a network failure, or a check the SDK made before sending)
codethe API's stable code, such as not_found, read_only, rate_limited or quota_exceeded, as listed in the files API errors; and, when a session or ticket is created, the API errors such as no_grant or payment_required
messagea readable description
current_etag / currentEtagfor precondition_failed: the file's ETag now
retry_after / retryAfterseconds to wait, from Retry-After or the answer

The SDK adds three codes of its own: network_error when there is no answer, api_error for an error answer without a code, and invalid_request when the API refuses the request's fields. API keys and tokens never appear in errors, or when a client or handle is printed.

  • Only a 2xx answer is a success. Redirects are not followed.
  • read, list and changes are sent once more after a network failure. write and delete are never retried: after a timeout their outcome is unknown. Read the file, or use a conditional write, before trying again.
  • HTTP errors other than the 401 session refresh are not retried. For 429, wait retry_after seconds.
  • A request fails with network_error after 60 seconds without an answer; changes waits 60 seconds plus wait. In TypeScript, the time to send a write's body counts toward those 60 seconds, so very large writes need a fast connection.

Source

Both SDKs are open source under Apache-2.0 in writeitai/mountable. The behaviour they share is tested case by case, as listed in the SDK contract. Packages: mountable-sdk on PyPI and mountable-sdk on npm.