> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tensorlake.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# File System HTTP API

> Create file systems, read and write files, list directories, and manage snapshots over plain HTTPS from any language, without a mount or an SDK.

Every file system operation the Tensorlake SDKs perform is an HTTPS request against `https://git.tensorlake.ai`. You can make those requests yourself from `curl`, a CI job, a browser, or a language the SDKs don’t cover. This page is the reference for that surface: the URL scheme, the credential you send, and every request and response shape a client needs.

<Note>
  The Python and TypeScript SDKs and the `tl fs` CLI wrap this API. Mounts add a local session and a bulk upload protocol on top of it. Use the raw API when you need a language the SDKs don’t cover, a serverless function with no `tl` binary, or a signed download link. See [Cloud Volumes](/filesystems/introduction) for the product overview.
</Note>

## URL scheme

A file system lives in a project and is addressed by name:

```text theme={null}
https://git.tensorlake.ai/project/{project}/repos/{filesystem}/fs/...
```

* **`{project}`**: the project id, such as `project_9f3c2a1b`. `tl git token` prints it, and the dashboard shows it in project settings.
* **`{filesystem}`**: the file system name you chose at creation, such as `agent-scratch`.
* **`{path}`**: a file or directory path inside the file system, given literally after `fs/files/` or `fs/presign/`. Percent-encode characters that aren’t valid in a URL.

File systems and Git repositories share the `/project/{project}/repos/{name}` namespace. Every file-system route sits under `/fs`. Calling a Git route on a file system returns `404`.

## Authenticate

File-system requests use HTTP Basic auth with a short-lived credential. The username is always `t`. The password is a token you mint from your Tensorlake API key or CLI login. Your API key itself is never sent to `git.tensorlake.ai`.

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    TOKEN=$(tl fs token agent-scratch --json | jq -r .token)
    ```

    `tl fs token` mints a credential scoped to one file system. Its JSON output has the same shape as the API response below.
  </Tab>

  <Tab title="API key">
    ```bash theme={null}
    curl --request POST \
      --url https://api.tensorlake.ai/artifact-storage/v1/token \
      --header "Authorization: Bearer your_api_key_here" \
      --header "Content-Type: application/json" \
      --data '{"repo": "agent-scratch"}'
    ```

    ```json theme={null}
    {
      "token": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJwcm9qZWN0XzlmM2MyYTFiIn0.Kx9f...",
      "tokenType": "bearer",
      "expiresAt": "2026-09-25T13:00:00Z",
      "gitUsername": "t",
      "repoPattern": "agent-scratch",
      "scopes": ["git:read", "fs:write"]
    }
    ```

    Omit `repo` to mint a project-wide credential (`repoPattern` is `*`). Send `"read_only": true` with a `repo` to mint a credential that can only read that file system.
  </Tab>
</Tabs>

Send the token as the Basic auth password on every request:

```bash theme={null}
curl -u t:$TOKEN \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch/fs/head"
```

Each route requires one scope:

| Scope          | Grants                                                                          | Minted by                            |
| -------------- | ------------------------------------------------------------------------------- | ------------------------------------ |
| `git:read`     | Every `GET` under `/fs`: head, entries, files, presign, snapshots, diff         | Any credential                       |
| `fs:write`     | Every mutation under `/fs`: write and delete files, retain and delete snapshots | A credential scoped to a file system |
| `repo:write`   | Create, fork, and delete file systems                                           | A project-wide credential            |
| `project:read` | List the project’s file systems                                                 | A project-wide credential            |

A file-system-scoped credential can’t push to Git repositories or delete the file system. Tokens expire after 1 hour by default. Mint a new one when a request answers `401`.

## Manage file systems

These routes need a project-wide credential.

### Create a file system

```text theme={null}
POST /project/{project}/repos/{filesystem}
```

```bash theme={null}
curl -u t:$TOKEN -X POST \
  -H "Content-Type: application/json" \
  -d '{"kind": "filesystem"}' \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch"
```

The response is `201 Created` with an empty body. `kind` must be `filesystem`; without it the route creates a Git repository. A name that already exists answers `409`.

### List file systems

```text theme={null}
GET /project/{project}/repos?kind=filesystem
```

```json theme={null}
{
  "project": "project_9f3c2a1b",
  "repos": [
    {
      "name": "agent-scratch",
      "full_name": "project_9f3c2a1b/agent-scratch",
      "default_branch": "main",
      "status": "active",
      "kind": "filesystem"
    }
  ],
  "next_after": null
}
```

Pages hold up to 1,000 entries. When `next_after` is set, repeat the request with `after=<next_after>` to fetch the next page.

### Get one file system

```text theme={null}
GET /project/{project}/repos/{filesystem}/meta
```

The response is one entry in the same shape as the list. This is an authoritative read: it sees a file system created milliseconds ago, and it works with a file-system-scoped credential. A missing file system answers `404` with the header `x-tensorlake-error: repo-not-found`.

### Fork a file system

```text theme={null}
POST /project/{project}/repos/{new_filesystem}/fork/{base_filesystem}?snapshot={snapshot_id}
```

The fork starts at the base’s current version, or at the permanent snapshot named by `snapshot`. It copies no file content: both file systems share the stored bytes until they diverge. The request takes no body. The response is `201 Created`.

### Delete a file system

```text theme={null}
DELETE /project/{project}/repos/{filesystem}
```

The response is `204 No Content`. Deletion removes the file system and its snapshots. Bytes shared with a fork remain until the last file system that references them is deleted.

## Read the current version

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/head
```

```json theme={null}
{
  "snapshot_id": "e3f421a78c8cbba09c79294131835fe0da8b4433a1b2c3d4e5f60718293a4b5c",
  "generation": 42,
  "last_autosave_ms": 1758801600000,
  "permanent_snapshot_count": 3
}
```

* **`snapshot_id`**: the id of the current version. It is `null` on a file system nothing has written to yet.
* **`generation`**: a counter that increases by 1 each time the file system’s shared state advances.
* **`last_autosave_ms`**: when a mount last published an autosave checkpoint, in Unix milliseconds. `null` if no mount has published.
* **`permanent_snapshot_count`**: how many permanent snapshots the file system holds.

Every read route accepts `?snapshot={snapshot_id}` to read a fixed version instead of the moving head. Pass the `snapshot_id` from this response to make a series of reads consistent with each other.

### Wait for a change

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/head?wait_generation={generation}&timeout_ms=30000
```

The request blocks until the generation exceeds `wait_generation` or the timeout elapses, then returns the head in the shape above. `timeout_ms` is clamped to between 100 and 55,000 and defaults to 30,000. Poll this in a loop to follow a file system without a mount.

## List a directory

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/entries?path={path}&snapshot={snapshot_id}&limit=1000&after={cursor}
```

```bash theme={null}
curl -u t:$TOKEN \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch/fs/entries?path=results"
```

```json theme={null}
{
  "entries": [
    { "name": "bench.json", "oid": "8f2c...", "mode": 33188, "size": 4096 },
    { "name": "plots", "oid": "1a9e...", "mode": 16384, "size": null },
    { "name": "latest", "oid": "c07b...", "mode": 40960, "size": 10 }
  ],
  "truncated": false,
  "next_after": null
}
```

* **`path`**: the directory to list. Omit it or pass an empty string for the root.
* **`name`**: the entry’s file name, without its directory.
* **`oid`**: a content id. Two files with the same `oid` have identical bytes.
* **`mode`**: the POSIX mode as a decimal integer. `33188` is a regular file with permissions `0644`, `16384` is a directory, and `40960` is a symbolic link. Mask with `0o170000` to get the type and `0o7777` for permissions.
* **`size`**: the file size in bytes, or the link target length for a symbolic link. `null` for directories.

Entries are sorted by name. `limit` is clamped to between 1 and 4,096. When `truncated` is `true`, pass `next_after` back as `after` to continue. A missing directory answers `404`. A directory holding a name that isn’t valid UTF-8 answers `422`.

## Read a file

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/files/{path}?snapshot={snapshot_id}
```

```bash theme={null}
curl -u t:$TOKEN -o bench.json \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch/fs/files/results/bench.json"
```

The body is the file’s bytes with `Content-Type: application/octet-stream`. The response carries these headers:

| Header                                | Meaning                                                     |
| ------------------------------------- | ----------------------------------------------------------- |
| `x-tensorlake-content-id`             | The file’s content id, the same value as `oid` in a listing |
| `x-tensorlake-content-hash-algorithm` | The hash that produced the content id                       |
| `x-tensorlake-mode`                   | The POSIX mode in octal, such as `100644`                   |
| `Accept-Ranges`                       | Always `bytes`                                              |

Send a `Range` header to read part of a file. The server answers `206 Partial Content` with a `Content-Range` header, or `416` when the range starts past the end of the file:

```bash theme={null}
curl -u t:$TOKEN -H "Range: bytes=0-1023" \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch/fs/files/results/bench.json"
```

A path that doesn’t exist answers `404`. A path that names a directory isn’t a file and also answers `404`.

## Write a file

```text theme={null}
PUT /project/{project}/repos/{filesystem}/fs/files/{path}?mode=644&message={message}&operation_id={id}
```

```bash theme={null}
curl -u t:$TOKEN -X PUT \
  --data-binary @bench.json \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch/fs/files/results/bench.json"
```

```json theme={null}
{
  "version_id": "7d1c9b2e4f6a8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d",
  "previous_version_id": "e3f421a78c8cbba09c79294131835fe0da8b4433a1b2c3d4e5f60718293a4b5c"
}
```

The request body is the raw file content. Parent directories are created as needed. Each write publishes one new version of the file system: `version_id` is the new head, and `previous_version_id` is the head the write was applied on top of. Following mounts see the file within seconds.

Query parameters:

* **`mode`**: the file’s permission bits in octal, such as `644` or `755`. Defaults to `644`.
* **`message`**: a description stored on the version. Defaults to `PUT {path}`.
* **`operation_id`**: an idempotency key. Two requests with the same key publish one version. When you omit it, the server derives one from the path and the content, so retrying an identical write after a lost response replays the first result instead of publishing twice.

One request writes at most 64 MiB. Larger bodies answer `413`. The SDKs and `tl fs push` upload larger files and whole directories through a multi-part protocol that streams bytes straight to object storage. Use them for bulk uploads.

Concurrent writers to different paths merge automatically. Two writers to the same path in overlapping windows are last-writer-wins. See [Concurrent Writes](/filesystems/concurrent-writes).

## Delete a file

```text theme={null}
DELETE /project/{project}/repos/{filesystem}/fs/files/{path}?message={message}&operation_id={id}
```

The response is the same `{version_id, previous_version_id}` object as a write. A path that doesn’t exist at the current head answers `404` and publishes nothing. When you omit `operation_id`, the server derives one from the path and the head the delete was issued against, so a retried delete replays and a fresh delete after the file was recreated publishes a new version.

## Get a presigned download URL

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/presign/{path}?snapshot={snapshot_id}&expires_in_secs=900
```

```json theme={null}
{
  "url": "https://storage.example.com/...?X-Amz-Signature=...",
  "expires_at_ms": 1758802500000,
  "size": 4096,
  "mode": "100644",
  "content_id": "8f2c..."
}
```

The URL serves the file’s bytes to anyone who holds it, with no `Authorization` header. Use it in an `<img>` tag or hand it to a service that can’t send credentials. `expires_in_secs` is clamped to between 60 and 3,600 and defaults to 900. Pin `snapshot` so the link keeps working after the file changes or is deleted at head.

A URL is minted only when one stored object holds exactly the file’s bytes. Small files stored inline and files assembled from several stored pieces answer `409` with the body `{"code": "not_presignable", "message": "..."}`. Fall back to `GET fs/files/{path}`, which serves every file.

## Snapshots

A file system has two kinds of versions, as described in [Core Concepts](/filesystems/core-concepts):

* **`auto_checkpoint`**: a version published by a write or a mount autosave. Tensorlake keeps the newest 256 and everything from the last 24 hours, then expires older ones.
* **`permanent_snapshot`**: a version you retained. It remains until you delete it.

Every `version_id` a write returns is a snapshot id you can read from, fork from, or retain, as long as it hasn’t expired.

### List snapshots

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/snapshots?limit=100&after={snapshot_id}
```

```json theme={null}
{
  "snapshot_ids": ["7d1c9b2e...", "e3f421a7..."],
  "snapshots": [
    {
      "snapshot_id": "7d1c9b2e...",
      "filesystem_id": "project_9f3c2a1b/agent-scratch",
      "root": "5b8e...",
      "parents": ["e3f421a7..."],
      "created_at_ms": 1758801600000,
      "principal": "user:diptanu",
      "message": "baseline benchmarks",
      "operation_id": "retain-baseline",
      "snapshot_class": "permanent_snapshot"
    }
  ],
  "next_after": null,
  "retention_policy": {
    "policy_version": "native_fs_v1",
    "configurable": false,
    "keep_last": 256,
    "keep_hours": 24,
    "permanent_snapshots": "until_deleted"
  }
}
```

The listing walks history newest first and includes both classes. Filter on `snapshot_class` to show only permanent snapshots, as `tl fs history` does. Pass `next_after` back as `after` to continue.

### Get one snapshot

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/snapshots/{snapshot_id}
```

The response is one entry in the shape above plus `permanence_epoch`, an integer that changes each time the snapshot is retained or released. Pass it as `expected_permanence_epoch` on a delete to make the delete conditional. An expired autosave checkpoint answers `410 Gone`.

### Make the current version permanent

```text theme={null}
POST /project/{project}/repos/{filesystem}/fs/snapshots
```

```bash theme={null}
curl -u t:$TOKEN -X POST \
  -H "Content-Type: application/json" \
  -d '{"message": "baseline benchmarks", "request_id": "retain-baseline-1"}' \
  "https://git.tensorlake.ai/project/project_9f3c2a1b/repos/agent-scratch/fs/snapshots"
```

```json theme={null}
{
  "snapshot_id": "7d1c9b2e...",
  "snapshot_class": "permanent_snapshot",
  "message": "baseline benchmarks",
  "principal": "user:diptanu",
  "request_id": "retain-baseline-1"
}
```

This is what `tl fs snapshot` and the SDK `snapshot()` call do. It promotes the current head to a permanent snapshot in place: no bytes are uploaded and no new version is created. `request_id` is an idempotency key; a retry with the same id returns the first result. If the head is already permanent the call is a no-op.

### Retain a specific version

```text theme={null}
POST /project/{project}/repos/{filesystem}/fs/snapshots/{snapshot_id}
```

The body is the same `{message, request_id}` object, plus an optional `expected_permanence_epoch`. Use it to retain a `version_id` a write returned before the autosave window expires it.

### Delete a permanent snapshot

```text theme={null}
DELETE /project/{project}/repos/{filesystem}/fs/snapshots/{snapshot_id}?expected_permanence_epoch={epoch}
```

The response is `204 No Content`. The version drops back to `auto_checkpoint` and expires with the retention window. Content still reachable from the head, a fork, or another snapshot stays stored.

## Diff two versions

```text theme={null}
GET /project/{project}/repos/{filesystem}/fs/diff?from={snapshot_id}&to={snapshot_id}&limit=1000&after={cursor}
```

```json theme={null}
{
  "changes": [
    {
      "path_utf8": "results/bench.json",
      "path_base64": "cmVzdWx0cy9iZW5jaC5qc29u",
      "before": null,
      "after": { "name": [...], "metadata": { "mode": 33188, "mtime_ns": 0, "uid": null, "gid": null, "xattrs": [] }, "data": { "File": { "size": 4096, "content": { "Blob": { "blob_id": "8f2c...", "logical_len": 4096 } }, "hardlink_group": null } } }
    }
  ],
  "next_after": null
}
```

Each change names one path whose entry differs between `from` and `to`. `before` is `null` for an added path and `after` is `null` for a removed one. The entry objects are the storage engine’s raw directory entries: `path_utf8` is `null` and `before`/`after` carry byte arrays for names that aren’t valid UTF-8. Compare `data.File.content` ids to tell a content change from a metadata-only change. Pass `next_after` back as `after` to continue.

## Errors

Error responses other than `409 not_presignable` carry a `text/plain` body with a one-line message. The status codes you should handle:

| Status | Meaning                                                                                                                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | A malformed id, cursor, mode, or body                                                                                                                                                 |
| `401`  | The credential is missing, expired, or revoked. Mint a new one                                                                                                                        |
| `403`  | The credential lacks the scope the route needs, or a quota was exceeded                                                                                                               |
| `404`  | The file system, path, or snapshot doesn’t exist. The `x-tensorlake-error` header is `repo-not-found` or `snapshot-not-found` when the missing thing is the file system or a snapshot |
| `409`  | The file system already exists, or a file isn’t presignable                                                                                                                           |
| `410`  | The snapshot expired from the autosave window                                                                                                                                         |
| `413`  | A `PUT fs/files` body exceeds 64 MiB                                                                                                                                                  |
| `416`  | A `Range` starts past the end of the file                                                                                                                                             |
| `422`  | A listing contains a name that isn’t valid UTF-8                                                                                                                                      |
| `503`  | The service is shedding load. Wait the number of seconds in `Retry-After`, then retry                                                                                                 |

Every write is idempotent under its `operation_id` or `request_id`, so retrying after a timeout or a `503` is safe.

## Not on this page

Mount sessions, the bulk multi-part upload protocol, and the raw byte-path routes the mount daemon uses are also HTTP, but their request shapes are coupled to the client that drives them. Use `tl fs`, the SDKs, or a [mount](/filesystems/filesystem-mounts) for those.

<CardGroup cols={2}>
  <Card title="Core Concepts" icon="lightbulb" href="/filesystems/core-concepts">
    File systems, sessions, autosave checkpoints, and permanent snapshots.
  </Card>

  <Card title="Platform Authentication" icon="key" href="/platform/authentication">
    API keys, personal access tokens, and SSO.
  </Card>
</CardGroup>
