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-sdkTypeScript 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-sdkOn 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
| Python | TypeScript | Does |
|---|---|---|
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.cursorTypeScript
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/mountableTypeScript
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/mountableThe 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.activeor 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:
| Field | Holds |
|---|---|
status | the HTTP status, or none when no answer arrived (a network failure, or a check the SDK made before sending) |
code | the 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 |
message | a readable description |
current_etag / currentEtag | for precondition_failed: the file's ETag now |
retry_after / retryAfter | seconds 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
2xxanswer is a success. Redirects are not followed. read,listandchangesare sent once more after a network failure.writeanddeleteare 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
401session refresh are not retried. For429, waitretry_afterseconds. - A request fails with
network_errorafter 60 seconds without an answer;changeswaits 60 seconds pluswait. 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.