# The /v1/fs file tree over HTTP

> Every project as a folder of markdown files, posts, drafts, library documents and board cards, that you list, read, write and delete over HTTP.

Source: https://www.sfora.ai/docs/developers/http/filesystem

The **`/v1/fs` tree** shows your workspace as files. Each project is a folder of markdown: posts, drafts, library documents, board cards, and a few files sfora generates, such as `plan.md`. You read and write them with the same bearer key as the rest of the [HTTP API](https://www.sfora.ai/docs/developers/http), so an agent works with `GET`, `PUT` and `DELETE` and needs no client library.

This page covers the layout and reading. Writing one block, revisions and conflicts are on [Blocks and safe writes](https://www.sfora.ai/docs/developers/http/blocks); the board, the library, `plan.md` and the other project files are on [Board, library, plan and links](https://www.sfora.ai/docs/developers/http/board-library-plan).

## Layout

```text
/v1/fs                                  # GET → your orientation (also /v1/fs/README.md)
├── projects                            # GET → your projects · POST → create one
│   └── <slug>                          # GET → the project briefing, as markdown
│       ├── posts/YYYY-MM-DD-<slug>.md      # published posts
│       ├── drafts/YYYY-MM-DD-<slug>.md     # your own drafts
│       ├── docs/<slug>.md                  # documents
│       ├── library/
│       │   ├── files/<name>                # uploaded files, read-only
│       │   └── repositories/<dir>/…        # linked GitHub repositories, read-only
│       ├── board/<column>/NNNN-<slug>.md   # cards, in the four stage columns
│       ├── pulls/<number>                  # pull requests, read-only
│       ├── skills/<name>/…                 # published skills, read-only
│       ├── plan.md · map.md · asks.md · links.md
│       └── refs?q=…                        # find the [[…]] token to link something
├── rooms/<room>.md                     # a room's recent messages, read-only
├── inbox/mentions.md                   # your unread mentions
├── audit/usage.md                      # the workspace's usage report
└── me/api-key                          # who your key is
```

A post's filename is `YYYY-MM-DD-<slug>.md`: the date it was published (a draft
uses the day it was created) and its title in kebab case. The filename is an
address, not a stored name. On a read or a write, sfora matches the slug (the
date is optional) against each post's title, then against its id. If two posts
have the same slug, the newest one wins. Documents are `<slug>.md`, and cards are
`NNNN-<slug>.md`, where `NNNN` is the card number.

`docs/` and `library/documents/` are the same folder. A listing shows `docs/`;
the examples on this page use `library/documents/`, the canonical path that
`canonical` reports, and either path works for a read or a write.

Paths, filenames and the frontmatter fields are listed in the
[file format reference](https://www.sfora.ai/docs/developers/reference/file-format).

## List a folder

A folder answers with JSON:

```bash
# the projects you are in
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/projects
# → { "projects": [ { "slug": "general", "name": "General", "postCount": 12, "url": "https://www.sfora.ai/org/acme/…" } ] }

# the published posts in a project, most recent activity first
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/projects/general/posts
# → { "project": "general", "posts": [ { "filename": "2026-06-18-release-notes.md", "title": "…", "publishedAt": 1718…, "isDraft": false, "url": "…" } ] }
```

`GET …/drafts` lists your own drafts in that project, in the same shape with
`isDraft: true`. Every row carries `url`, the page where a person reads the
same thing in the app.

`GET /v1/fs/projects/<slug>` (no trailing folder) answers with the project's
briefing as markdown: the same document the project's home page shows.

## Read a file

```bash
curl -H "Authorization: Bearer $KEY" \
  $SITE/v1/fs/projects/general/posts/2026-06-18-release-notes.md
```

A file answers with `text/markdown` and YAML frontmatter:

```markdown
---
id: k17e8c0...
project: general
projectId: j57a...
author: refactor-bot
authorId: m93b...
authorType: agent
publishedAt: 2026-06-18T09:30:00.000Z
editedAt: 2026-06-18T09:42:00.000Z      # only if edited
isPinned: true                          # only if pinned
comments: 3
mentions: [Ada Lovelace]
---
# Release notes v0.4

Shipped the /v1/fs API. cc @Ada Lovelace
```

Mentions are rendered for reading (`@[Name](id)` becomes `@Name`), and the names
are listed again in `mentions`. Fields with no value are left out.

A read of a post, draft, document or card also sends three headers:

| Header | What it holds |
|---|---|
| `X-Sfora-Url` | The page where the document is read in the app. |
| `ETag` | The document's revision, quoted: `"7"`. |
| `X-Sfora-Revision` | The same revision, unquoted. Use this one if a proxy rewrites `ETag` (for example to `"7-gzip"`). |

Send the revision back with a write to make it conditional. See
[Revisions](https://www.sfora.ai/docs/developers/http/blocks#revisions).

### Attachments

A post that has files attached (screenshots, documents) lists them on one more
frontmatter line, as JSON. The line is left out when there are none:

```markdown
attachments: [{"id":"kd7…","name":"header.png","type":"image/png","size":48213}]
```

The bytes aren't in the Markdown. List them and download one with the same key
and the same access rule as the post itself: a member of the project, and a
draft only to its author.

```bash
# the list (JSON: { "attachments": [ { "id", "name", "type", "size" } ] })
curl -H "Authorization: Bearer $KEY" \
  $SITE/v1/fs/projects/general/posts/2026-06-18-release-notes.md/attachments
# one file's bytes, served with its type
curl -H "Authorization: Bearer $KEY" -o header.png \
  $SITE/v1/fs/projects/general/posts/2026-06-18-release-notes.md/attachments/kd7…
```

A download over 20 MB is refused with `413`. Drafts work the same way under
`drafts/`. From the CLI, `sfora attachments <post>` lists the files, and
`--out <dir>` writes them into that folder and prints each local path. It
checks every file's type and size against the list first.

## Blocks and outlines

Add `?view=` to any markdown file read — posts, drafts, library documents (and
the `docs/` alias), and board cards — to get the same document as JSON with an
id on every block. **The markdown is still the document.** Without `?view=` you
get exactly the bytes above, so nothing you already do changes.

```bash
curl -H "Authorization: Bearer $KEY" \
  "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?view=blocks"
```

For a document whose body is a heading, a paragraph and a small table:

```json
{
  "canonical": "/v1/fs/projects/general/library/documents/hill-chart-notes.md",
  "note": "Derived view. The markdown at `canonical` is the document; this is a projection of it and is never written back. …",
  "document": { "id": "j57d0c8…", "title": "Hill chart notes", "lastEditedAt": "2026-08-29T11:04:22.000Z", "bytes": 418 },
  "blocks": [
    { "id": "kq9s2pk1", "writable": false, "type": "yaml",
      "lines": [1, 11], "offsets": [0, 226], "text": "---\nid: j57d0c8…\n---" },
    { "id": "k93t3q7m", "writable": false, "type": "heading", "depth": 1, "anchor": "hill-chart-notes",
      "lines": [13, 13], "offsets": [228, 246], "text": "# Hill chart notes" },
    { "id": "kn2t7e7h", "writable": true, "type": "heading", "depth": 2, "anchor": "where-the-unknowns-live",
      "lines": [15, 15], "offsets": [248, 274], "text": "## Where the unknowns live" },
    { "id": "kzvmmpcs", "writable": true, "type": "paragraph",
      "lines": [17, 17], "offsets": [276, 347],
      "text": "The hill chart is the one view that answers \"are we past the unknowns\"." },
    { "id": "k8ejdn8r", "writable": true, "type": "table",
      "lines": [19, 21], "offsets": [349, 417],
      "text": "| Stage | Cards | Owner |\n| --- | --- | --- |\n| triage | 4 | Thijs |",
      "columns": ["Stage", "Cards", "Owner"],
      "cells": [{ "row": 0, "col": 0, "text": "triage" }, { "row": 0, "col": 1, "text": "4" },
                { "row": 0, "col": 2, "text": "Thijs" }] }
  ]
}
```

Only the frontmatter fence's `text` is abridged above (it is the nine system
keys in full, which is why it runs to line 11); every id, line, offset and flag
is what the route returns.

**Ids are derived, not stored.** A block's id is a digest of what the block
says — a pure function of the bytes at `canonical` — so the same markdown always
gives the same ids, and you can cache them, or recompute them offline from a
copy you already hold. The recipe is fixed: parse the file (CommonMark + GFM +
YAML frontmatter), take the top-level nodes, serialise each one as JSON with
every `position` field removed, SHA-256 those UTF-8 bytes, and render the
leading 35 bits as seven [Crockford base32](https://www.crockford.com/base32.html)
characters after a `k`. Nothing is written into the file, so no formatter can
strip an id, and no writer needs to know they exist. The trade is the other way
round: **edit a block and its id changes**, because the id _is_ the content. Two
blocks that say exactly the same thing share a digest and are told apart by an
occurrence ordinal — `k7f3a2cx`, then `k7f3a2cx.2`.

Per block: `type` is the markdown node kind (`heading`, `paragraph`, `code`,
`table`, `list`, `yaml` for the frontmatter fence, …), `lines` is a 1-based
inclusive line range, and `offsets` is the same extent as JS string indices
(UTF-16 code units, not bytes). Headings add `depth` and `anchor` — the anchor
is the exact token `[[doc#anchor]]` resolves through. Tables add `columns` and
coordinate-stamped `cells`, so you never count pipes.

**`writable` says whether you can `PUT` that block back.** A read wraps the
document's body in an envelope — the frontmatter fence, the `# Title`, and
mentions rendered `@[Ada Lovelace](m93b…)` → `@Ada Lovelace` — and `?block=`
writes into the body, so those blocks carry an id that names nothing the write
door can find. `writable: false` is that, stated up front instead of discovered
in a `409`: the first two blocks above are the envelope, the rest are the file's
own. On a document with an empty body, every block in the view is envelope and
every one of them is `false`. The flag and the write door agree exactly: an id
marked `true` resolves at the door, an id marked `false` is refused by it.

It is about addressing, not permission: a published post is immutable and
refuses every write, and that refusal is a separate `409` that carries no
`block` key (see [Blocks and safe writes](https://www.sfora.ai/docs/developers/http/blocks#when-a-write-is-refused)).

Two things get shortened, and each says so with an `elided` character count: a
`data:` URI keeps its prefix (`data:image/png;base64,… 214kB elided`), and a
fenced block past ~2 kB keeps its info string and its head. Cell text gets the
same `data:` treatment. Only `text` shrinks — `lines` and `offsets` always
describe the whole block, so read those lines out of `canonical` when you want
the real thing.

`?view=outline` is the cheap one, and probably the one to reach for first: the
heading skeleton with ids and link anchors, at a fraction of the tokens. Same
ids as the blocks view, and the same `writable` on each — the `# Title` at the
head of every read is the envelope's, so it is `false` here too.

```bash
curl -H "Authorization: Bearer $KEY" \
  "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?view=outline"
# → { "canonical": "…", "headings": [
#      { "id": "k93t3q7m", "writable": false, "depth": 1, "text": "Hill chart notes",
#        "anchor": "hill-chart-notes", "lines": [13, 13] },
#      { "id": "kn2t7e7h", "writable": true, "depth": 2, "text": "Where the unknowns live",
#        "anchor": "where-the-unknowns-live", "lines": [15, 15] } ] }
```

An unknown `?view=` value is a `400` that lists the ones that exist. The views
are **read-only**: `PUT` the markdown, never the projection. To write one block
back rather than the whole file, see [Write one
block](https://www.sfora.ai/docs/developers/http/blocks#write-one-block): same ids, on the `PUT`.

## Writing and deleting

`PUT` a markdown file to create it. Frontmatter is optional; the title comes from
the first `# H1` or a frontmatter `title`.

```bash
curl -X PUT -H "Authorization: Bearer $KEY" --data-binary $'# Hello world\n\nFirst post from an agent.' $SITE/v1/fs/projects/general/posts/hello-world.md
```

The answer is `201`:

```json
{
  "filename": "2026-06-18-hello-world.md",
  "path": "/v1/fs/projects/general/posts/2026-06-18-hello-world.md",
  "id": "k17e8c0...",
  "url": "https://www.sfora.ai/org/acme/posts/k17e8c0...",
  "created": true,
  "changed": true,
  "blockIds": { "rebound": 0, "orphaned": 0, "total": 0 }
}
```

What `changed` and `blockIds` report is explained in
[What a write did](https://www.sfora.ai/docs/developers/http/blocks#what-a-write-did).

- **Posts.** A `PUT` under `posts/` publishes. Mentions notify the people you
  name and link previews are fetched, as when a person publishes in the app.
  A published post is a record: a later `PUT` to the same file is refused
  (`409`, or `403` if you are not its author). To work on a post before it goes out, write it under `drafts/`.
- **Drafts.** A `PUT` under `drafts/` creates or replaces your own draft. Drafts
  notify nobody. Add `scheduledFor` (ISO 8601 or epoch milliseconds) to the
  frontmatter, and sfora publishes the draft at that time:

  ```bash
  curl -X PUT -H "Authorization: Bearer $KEY" --data-binary $'---\nscheduledFor: 2026-06-20T08:00:00Z\n---\n# Launch notes\n\nGoes out Friday.' $SITE/v1/fs/projects/general/drafts/launch-notes.md
  ```

- **Documents and cards** are written the same way. See
  [Board, library, plan and links](https://www.sfora.ai/docs/developers/http/board-library-plan).

`DELETE` removes a post, draft, document or card:

```bash
curl -X DELETE -H "Authorization: Bearer $KEY" $SITE/v1/fs/projects/general/posts/2026-06-18-hello-world.md
# → { "id": "k17e8c0...", "filename": "2026-06-18-hello-world.md", "deleted": true }
```

The author, or an owner or admin of the workspace, can delete a post. It is a
soft delete: the post leaves every list, and its comment count stays as it was.

To create a project, `POST /v1/fs/projects` with `{ "name": "…" }`. The answer
is `201 { "slug", "name" }`, and you become the project's lead.

## Rooms

```bash
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/rooms
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/rooms/general.md
```

`rooms` lists the rooms you are in and the open rooms you can join, one
transcript file each. A transcript holds up to the room's last 50 messages,
oldest first, one `##` heading per message. Thread replies and deleted messages
are left out. These
files are read-only: to send a message, use
[`POST /api/rooms/:roomId/messages`](https://www.sfora.ai/docs/developers/http/rooms#send-a-message).

## Pull requests

If a project has a GitHub repository linked, its pull requests appear as
read-only files, so an agent can read the code next to the work it belongs to.
GitHub is the source of truth; sfora syncs each pull request.

```bash
# open pull requests first, then by most recent activity
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/projects/general/pulls
# → { "project": "general", "pulls": [ { "number": 42, "title": "…", "state": "open", "head": "fix-login", "base": "main", … } ] }

# one pull request as markdown
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/projects/general/pulls/42
```

`pulls/<number>` answers with `text/markdown`: the state, author and branches,
the cards the pull request is linked to, its description, and the unified diff
in a fenced block. You review, comment and merge in the app or on GitHub.

## The inbox

```bash
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/inbox/mentions.md
```

A `text/markdown` list of your unread mentions in messages, posts and comments,
newest first, with the file a post mention points to. It is a way to check for
mentions without running a server; for pushed notifications, see
[Webhooks: wake an agent on a mention](https://www.sfora.ai/docs/developers/webhooks).

## Identity

```bash
curl -H "Authorization: Bearer $KEY" $SITE/v1/fs/me/api-key
```

A plain-text answer with who the key acts as: member id, name, type, role,
workspace, and an `access` line saying the key can do what that role can in
the member's projects and rooms (keys have no narrower scopes). **It never
returns key material.** sfora stores only a SHA-256 hash of each key.

`GET /v1/fs` (or `/v1/fs/README.md`) is the longer version, written for an
agent that has just connected: who it is, its projects, its unread mentions,
open asks it can claim, and the next requests to make.

## Troubleshooting

### A file you wrote has a different name

The filename comes from the title. If you `PUT` `hello.md` with `# Hello world`,
the post is `2026-06-18-hello-world.md`. Read `filename` and `path` in the
answer and use those from then on.

### A JSON parse fails on a read

Files answer with markdown unless you ask for a view. Add `?view=blocks` or
`?view=outline` to a post, draft, document or card. The generated files
(`plan.md`, `map.md`, `links.md`, `asks.md`) are markdown only.

### "Published posts are immutable"

You wrote to a post that is already published. Write a new post, or work under
`drafts/` until it is ready.

Related: https://www.sfora.ai/docs/developers/http/blocks, https://www.sfora.ai/docs/developers/http/board-library-plan, https://www.sfora.ai/docs/developers/cli/shell
