# Thousand, for AI agents

You were pointed at a Thousand workspace — a team's documentation, kept in git,
served with path-level access control. This page is the entire manual. It is
always at `/AGENTS.md` on the same host, and everything on it works with the
tools you already have: git and HTTP.

**What Thousand is.** A git host with a reading and writing surface on
top. Each workspace is a real git repository that lives on this server —
Thousand is the remote you clone from and push to, the way a code host
is; it does not sit on top of another host, and no other service holds
the repository. Everything else — the editor, comments, access rules,
this API — reads and writes that repository: an edit made in the app is
a draft until its author commits it (nothing is ever committed on a
timer), an agent's edit is a commit, a comment is a file in it, and a
git push through the same URL is an edit like any other. There is no separate database of documents that git
syncs to. Repositories that live
elsewhere are connected as **sources** (see Synced folders): folders of
them kept equal to folders here, read-only or two-way.

**Index.** Everything on this page, by what you want to do. Each row
names the MCP tool, then the raw HTTP call; the section it links to has
the details.

| you want to | MCP tool | HTTP |
|---|---|---|
| get a credential | (built into the MCP server) | `POST /api/device/start`, `POST /api/device/poll` — [Getting in](#getting-in) |
| know where you are, list workspaces | `workspace_info` | `GET /api/agent/workspaces` — [Getting in](#getting-in) |
| clone, pull, push | — | `https://<host>/git/<workspace>.git` — [Working over git](#working-over-git) |
| list, search, read a document | `list_documents`, `search`, `read_document` | `GET /api/agent/read` — [The agent API](#the-agent-api) |
| write a document | `write_document` | `POST /api/agent/edit` — [The agent API](#the-agent-api) |
| see what changed | `diff_document` | `GET /api/agent/diff` — [The agent API](#the-agent-api) |
| clear a draft you folded into a commit | `discard_draft` | `POST /api/agent/draft/discard` — [The agent API](#the-agent-api) |
| know who can read a path | `audience`, `check_access` | `GET /api/agent/audience`, `GET /api/agent/permissions` — [Know your audience](#know-your-audience-before-you-write) |
| comment, delete a comment | `comment`, `delete_comment` | `POST /api/agent/comment`, `POST /api/agent/comment/delete` — [Comments](#comments) |
| grant or revoke access | `grant_access` | `POST /api/agent/access` — [The agent API](#the-agent-api) |
| connect a repository, sync, resolve, disconnect | `connect_source`, `sync_source`, `resolve_source`, `update_source`, `disconnect_source` | `POST /api/agent/sources`, `…/sync`, `…/update`, `…/resolve`, `…/remove` — [Synced folders](#synced-folders) |
| delete documents or folders | `delete_document` | `POST /api/agent/delete` — [Deleting things](#deleting-things) |
| create a workspace | `create_workspace` | `POST /api/agent/workspaces/new` — [Getting in](#getting-in) |
| delete a workspace (needs a human) | `delete_workspace`, `poll_approval` | `POST /api/agent/workspaces/delete`, `POST /api/agent/approvals/poll` — [Deleting things](#deleting-things) |

Two ways in. **Git** when you work in a filesystem (Claude Code, Codex,
Cursor): clone the workspace, edit files, push. **The agent API** when you were
given an agent key, or when you only need to read, comment, or check access.

## MCP — the one-command way

If your client speaks MCP, everything below is packaged as a server with
browser sign-in built in:

    claude mcp add --transport http thousand https://<host>/mcp
    codex mcp add thousand --url https://<host>/mcp

First use opens the browser: sign in, tick the workspaces this connection
may work in, approve, done. The tools are
workspace_info, list_documents, search, read_document, write_document,
diff_document, comment, delete_comment, audience, check_access,
grant_access, connect_source, sync_source, resolve_source, update_source,
disconnect_source, create_workspace, delete_document, delete_workspace,
poll_approval — same rules
as everything below, and `workspace_info` hands you the git clone URL and
the shape of a document link when you want either. The token it stores is
a personal token, revocable on /tokens.

**Which workspace a call means.** A connection reaches the workspaces
your human ticked when they approved it, and no others. Every tool takes
an optional `workspace`; leave it out and the call acts in the
connection's default (the workspace the approval happened in).
`workspace_info` tells you both: `connected` (the list) and `default`.
With one workspace connected there is nothing to choose. With several,
name the one you mean on each call — never guess from the conversation
which one a document is in; ask `workspace_info` or your human.

A call naming a workspace outside the connection is refused, not
silently redirected: `ok: false`, `connected: [...]`, and `add_url`
(`https://<host>/tokens?add=<workspace>`). Hand your human the `add_url`
and tell them what it does: it opens a one-button approval naming the
workspace and this connection; when they click Allow, this same token
reaches the workspace from then on — no reconnect, no new token — and
you retry the call. Do not send them to the tokens page to look for a
setting; the link is the whole step. If the workspace does not exist
yet, `create_workspace` makes it and joins it to your connection in the
same step. Your human edits or revokes any connection on /tokens under
"Connected apps". Tokens an agent obtained by device sign-in, or through
MCP before this existed, are connections too, to the workspace they
were made in.

Two separate connections (two MCP servers in your client, each approved
for its own workspace) are also fine, and are the right shape when the
workspaces must never share a session.

No MCP in this session? Servers attach only at session start, so carry on
with the device sign-in below — but tell your human the one command above
makes every future session smoother.

## Getting in

One credential does everything: a **personal token** (`td_…`). It works as
the git password AND as the `key` in every API call below. It is your
identity: you act as the user it belongs to, in their workspace, under their
access.

**Get one without leaving the conversation** (device sign-in — the
`gh auth login` shape). Start:

    POST /api/device/start            → {user_code, device_secret,
                                         verification_url, interval}

Show your human the `user_code` and `verification_url`, and tell them:
"open this link, sign in, approve the code, come back." Three things that
decide whether this takes ten seconds or ten minutes:

- **If you run on their machine** (a CLI agent with a shell), open it for
  them rather than making them copy a URL: `open <url>` on macOS,
  `xdg-open <url>` on Linux, `start <url>` on Windows. Show the code anyway —
  they must confirm it matches what you displayed. Pasting the URL into a
  shell instead of a browser is a common miss; `?` is a glob character there.
- **Do not try to approve it yourself** in a headless or sandboxed browser.
  Approval needs their signed-in session, and your browser is not theirs —
  signing in would mean handling their password, which you must never do.
  **Open `verification_url` in their default browser yourself** when you
  can (`open <url>` on macOS, `xdg-open <url>` on Linux, `start <url>` on
  Windows) — the prompt should appear in front of them, not be a link
  they have to go find. Show the link too as the fallback, then poll.
  The URL carries only the short code, never the secret, so opening it
  is safe.
- **Keep the `device_secret` outside any page you navigate.** It lives for
  one exchange only, and losing it means starting over with a new code.

Then poll every `interval` seconds:

    POST /api/device/poll
    {"device_secret": "..."}          → {"status": "pending"}
                                      → {"status": "approved", "token": "td_…",
                                         "user": …, "workspace": …,
                                         "git": <clone URL, no token in it>}

Codes expire in 15 minutes. The token appears on their /tokens page as
"agent (device sign-in)", revocable any time.

**Or the manual way:** your human visits `https://<host>/tokens`, mints a
token, and pastes it to you. Same credential, same powers.

There is nothing else to set up. No SDK, no OAuth for you to drive.

Once you hold a token, `GET /api/agent/workspaces?key=td_…` (or the
`workspace_info` MCP tool) lists every workspace your human belongs to —
the same list their sidebar switcher shows. The token itself acts only
in the workspace it was minted in: to work in another one, run the
device flow again from that workspace's host path, or ask for a token
minted there. One leaked token never unlocks the whole account.

You can also **open a new workspace** for your human — the MCP tool
`create_workspace`, or:

    POST /api/agent/workspaces/new
    {"key": "td_…", "ws": "acme-docs"}   → {ok, workspace, token, git, web}

Same checks and caps as the /new page. The response's `token` is a fresh
credential minted **in the newborn workspace** — use it there; the one
you called with keeps acting only where it was minted. Your human sees
the new token on that workspace's /tokens page, revocable any time.

## Working over git

    git clone https://thousand:<token>@<host>/git/<workspace>.git

That is a complete, ordinary git repo — real paths, real history. Work
normally: pull, commit, push. What you must know:

- **The clone is your grant.** Folders you cannot see do not exist in the
  repo, not even in history. If a folder you expect is missing, you were not
  granted it — ask your human, do not go looking for it. How: the
  workspace has one canonical repository, and every distinct set of
  granted paths gets its own derived repository, built from the canonical
  one with the other paths filtered out of every commit. Your token
  decides which repository the git URL serves. A push into a derived
  repository is replayed into the canonical one, checked path by path,
  and flows out to every other derived repository from there — so an
  owner's full clone and a member's partial clone are different
  repositories with different commit ids for the same change.
- **One commit per top-level folder.** Commit messages travel with shared
  folders, so a commit spanning two of them is refused at push. Split by
  folder, and write each message for the audience of that folder.
- **A rejected push explains itself.** Read the message; it names the file
  and the fix. Common ones: a path outside your write access, a commit mixing
  folders, an edit to a platform-owned file.
- **An accepted push can carry a `note:`.** One names a pushed file that
  has unsaved edits in the app — a person's draft, which no fetch shows.
  See [Someone's unsaved edits](#the-agent-api): read the draft through
  the API, and clear it once your commit carries it.
- **Platform-owned files.** Never edit `<folder>/.thousand-access.md`
  (generated audience notes) or `<folder>/.comments/` (comment sidecars),
  and never create `.thousand/` — access rules live on the server, not in
  files, and pushes carrying that directory are refused.
- Only `main` is served. History is append-only; force-pushes are refused.
- **Push through the host `workspace_info` hands you** (`git.usethousand.com`
  when hosted). It serves git directly, with no proxy in front, so a large
  first push carries the whole history in one go. The same path also answers
  on the main hostname, but a CDN proxy sits in front of it and cuts request
  bodies at 100 MB — a big push there dies with a bare `413` before Thousand
  ever sees it. Limits that are Thousand's own, with real error messages:
  25 MB per file, 1 GB per repo.

**The welcome folder.** A fresh workspace is born holding `welcome/` — a
few example documents showing what the place can hold. They are samples,
nothing depends on them, and everything fictional lives under that one
folder. If your human doesn't want them (importing a real repo, or just
done with them), remove them in one move — `git rm -r welcome` and
commit, or delete the folder in the app. Never treat their contents as
the team's real facts.

**Importing an existing repo.** A fresh workspace holds only the
`welcome/` examples above — a path no real repo owns, so nothing of
yours can collide with it — but its history is still unrelated to yours,
and force-pushes are refused, so the two are joined by a merge (drop the
examples in the same breath):

    git remote add thousand https://thousand:<token>@<host>/git/<workspace>.git
    git pull thousand main --no-rebase --allow-unrelated-histories
    git rm -r welcome && git commit -m "imported over the examples"
    git push thousand main

If the repo carries a `.thousand/` directory from an older Thousand
workspace, delete it first (`git rm -r .thousand` and commit) — the push is
refused while the current tree contains it, and rebuild the grants with
`grant_access` or on /access.

## Know your audience before you write

Every shared folder carries `.thousand-access.md` — who can see it, tier by
tier. Files AND commit messages there are visible to everyone it lists. Check
it before writing anything sensitive, or ask fresh for any path:

    GET /api/agent/audience?key=<KEY>&path=<path>

## Comments

Humans discuss documents in the margin; the discussion is stored as markdown
beside the document (`gtm/verdict.md` → `gtm/.comments/verdict.md`), so a
clone carries every conversation. Read them freely — `/api/agent/read`
returns a document's comments with it.

To leave one, never edit the sidecar — post it:

    POST /api/agent/comment
    {"key": "<KEY>", "path": "gtm/verdict.md", "body": "…",
     "quote": "<the passage it is about>",          # optional
     "parent": 0}                                   # optional: reply to id

It lands in the document's margin and in the sidecar, attributed to the user
your key maps to.

Clean up after yourself. Working notes you left in someone's margin are
yours to remove — and only this can remove them, since the sidecar is
platform-owned and git will not touch it:

    POST /api/agent/comment/delete
    {"key": "<KEY>", "id": 8}      # your own comments; owners, anyone's

Replies go with the comment. Deletion is not undoable.

## The link to hand your human

Every document has a web address, and it is not the file path alone:

    https://<host>/w/<workspace>/d/<path>
    e.g. https://<host>/w/acme/d/gtm/verdict.md

It names the workspace, so it opens for your human no matter which
workspace their browser is in (they are switched there if they belong to
it). The older `/d/<path>` shape still works but reads the browser's
current workspace, so never hand that one out: a person sitting in
another workspace gets Not found. Every API answer that names a document
carries the built URL in `url` (a posted comment's `url` links straight to
it), and `workspace_info.doc_url` gives the shape, so quote those rather
than assembling one. Do not try to discover routes by probing: signed out, every
path answers the same redirect to /login, real or invented — deliberately,
so nobody can map a workspace from outside.

## A shared link, with no account

A person may hand you a shared link instead: `https://<host>/s/<token>`.
It is the credential, for that one document only, and it needs no token
or account. Fetch `/s/<token>/agents.md` for its instructions, or ask
for the link itself with `Accept: text/markdown`: you get the
instructions followed by the document.

    GET  /s/<token>/md             the markdown; its ETag is the version
    GET  /s/<token>/md?download=1  the same, as a file
    PUT  /s/<token>/md             edit links only: the whole new text

A PUT (or POST) takes the markdown as the body, `If-Match: <ETag>`, and
optionally `X-Commit-Message`; or JSON `{text, base, message}`. It lands
as one commit under the link's name, which the document's owners see and
can undo. If the document moved since you read it, the answer is `409`
with the current `text` and `base`: fold your change in and send again.
A read link answers a PUT with `403`. A revoked link answers `404`
everywhere, on the next call.

## Drawings, tables and pictures

A diagram belongs in the workspace as a drawing, not as a picture of one.
Write it as an Excalidraw file, `<name>.excalidraw`, with `write_document`
(or `POST /api/agent/edit`), like a document: the text is the file's JSON,

    {"type": "excalidraw", "version": 2, "elements": [...], "appState": {}, "files": {}}

It opens in the app as a live canvas people can edit, at the same
`/w/<workspace>/d/<path>` address a document has; JSON the canvas cannot
open is refused. A table is a `.csv` file, written the same way.
`read_document` reads both.

To show a drawing inside a document, embed it like an image, relative to
the document:

    ![The v9 pipeline](v9-pipeline.excalidraw)

The page draws it in place, and a reader clicks it to open the drawing.
Do not render a drawing to a picture yourself: the embed is always the
current drawing, and a picture goes stale the first time someone edits it.
Ordinary pictures (`.png`, `.jpg`, `.gif`, `.webp`, `.svg`) embed the
same way, but reach the repo over git (see "Working over git").

## The agent API

Base URL is the host you were given. Auth is the `key` parameter. You inherit
the ACL of the user the key maps to; sealed paths refuse.

A key minted by browser consent (an MCP connection, or the device flow)
may reach several workspaces. Say which with `?workspace=<name>` on any
route below; omitted, the key's default workspace is meant. A workspace
the key was not approved for answers `403` with
`WWW-Authenticate: Bearer error="insufficient_scope", scope="ws:<name>"`
and a JSON body carrying `connected` and `add_url` — give the `add_url`
to your human; it is a one-button approval, and the key reaches the
workspace the moment they click Allow. A key created on
/tokens by hand lives in one workspace and needs no `workspace`.
`GET /api/agent/workspaces?key=<KEY>` lists every workspace the person
belongs to, connected or not.

Send a real User-Agent header naming your client (`my-agent/1.0`). A CDN
sits in front of the hosted API, and a default library UA like
`Python-urllib` can be refused at that layer with an opaque 403 error 1010
that never reaches Thousand.

**Read** — returns the file, its comments, current HEAD, and `base`
(a content hash — round-trip it when you edit):

    GET /api/agent/read?key=<KEY>&path=<path>

**Write** — you express edits; the platform owns git and decides how they land:

    POST /api/agent/edit
    {"key": "<KEY>",
     "task": "short-slug",            # optional, names the branch if isolated
     "message": "commit message",     # optional
     "edits": [{"path": "...", "text": "full new contents",
                "base": "<from read>"}]}   # optional but ALWAYS send it

`base` is your edit's seatbelt. An edit writes the whole file, and someone
else may have committed to the same document since you read it. With
`base` set, a document whose committed text moved since your read refuses
the edit and hands you the current text and its new base: merge your
change into that text and resend. Without `base`, you overwrite them
silently.

A person may also be typing in that document right now. Their draft is
theirs: `text` is still the last commit, and your commit does not touch
the draft. The app carries it over your commit when the lines differ,
and shows them what you changed and a choice when the lines collide. A
document that exists only as someone's draft reads as "not committed
yet". What you can do about a draft is its own section below.

**Diff** — what changed, before you write or after you look away:

    GET /api/agent/diff?key=<KEY>&path=<path>            # uncommitted draft vs HEAD
    GET /api/agent/diff?key=<KEY>&path=<path>&since=<sha> # everything after that commit

Returns the unified diff, `dirty` (is there an uncommitted draft), `head`,
`base`, a `draft` note when someone is mid-edit, and with `since` the
commit list too. An empty diff with `dirty: false` means the document is
exactly its last commit.

The response says what happened: `"mode": "live"` — committed to main
under your name with your `message`, visible at once; `"mode": "isolated"` —
committed to a private branch, **invisible until a human merges it** on
/proposals. Policy picks the mode; you may request `"isolated"` but cannot
force `"live"`. The line is visible, not a trap: every response carries
`"gauge"` — this edit's `delta_bytes` (the size of the CHANGE, not the
file) and the `isolates_over` thresholds (defaults: 8000 changed bytes,
or more than 2 files; folders can be pinned either way by server policy).
An isolated response says `"why"`. Sending the same isolated edit twice
lands on the same branch — retries never stack duplicate proposals.

A live edit is a commit, the same as a git push. Only a person's typing
in the app has a draft stage, and only that person ends it, by committing
or discarding — or their own agent does, below. If your commit lands
under someone's draft, the response says so (`"note"`): they will be
shown your change beside theirs.

**Someone's unsaved edits** — `read` (and `diff`) carry a `draft` when a
person is mid-edit on the document:

    "draft": {"by": "Ana", "user": "ana@x.io", "since": 1758300000,
              "state": "ok" | "behind", "text": "…the draft as it is…",
              "base": "<hash of that text>", "note": "…"}

The usual case: your human made edits in the app, did not commit them,
and now asks you to work on the same document from a clone or through
this API. Read the draft, fold what matters into your version, commit —
and then clear the draft, so their editor moves to the latest instead
of warning them that main changed under it:

    POST /api/agent/draft/discard
    {"key": "<KEY>", "path": "notes/plan.md",
     "base": "<draft.base from read>",     # always send it
     "into": "<sha of your commit>"}       # optional; found by itself

`base` is the draft's seatbelt, as it is for edits: a draft that grew
since your read is refused with its current text, not thrown away.
Only the draft's own author may clear it — you, acting as them with
their token. Someone else's draft is refused: commit your version and
the app shows them yours beside theirs. Nothing in history changes;
the person is told, next time they open the document, which commit
carries their edits.

Over git the draft is invisible to a fetch, so a push that changes a
drafted file prints a `note:` naming it. Read it through the API and do
the same.

**Audience** — see above. **Comment** — see above.

**Permissions** — what someone may do, folder by folder, without anyone
opening the /access page:

    GET /api/agent/permissions?key=<KEY>              # your own access
    GET /api/agent/permissions?key=<KEY>&who=sam    # owners only
    GET /api/agent/permissions?key=<KEY>&path=notes/**

**Access** — grant or revoke, per folder or per document:

    POST /api/agent/access
    {"key": "<KEY>", "who": "sam", "path": "collaborations/**",
     "tier": "commenter"}      # editor | commenter | reader | "" to revoke
    {"key": "<KEY>", "who": "sam", "path": "gtm/verdict.md",
     "tier": "reader"}         # a single document works too

Who may share follows the Drive rule: an **owner** governs everything; an
**editor** may share what they can edit, at up to their own tier; commenters
and readers share nothing. A single-document grant shadows the folder rule
for that document only; a folder-level change flows through every file rule
beneath it, so a folder revoke revokes everywhere. Any ancestor folder is a
grantable unit (`research/interviews/**`), and `"days": 30` makes a grant
temporary — it stops working the moment it expires. Rules live on the
server; every change is logged and live immediately, and the folder's
audience note is regenerated.

If a path you need answers "sealed", ask rather than guessing at rules:
your human can request access from the document page, and owners decide on
/access.

## Standalone agents

An agent can also be a member of its own — for shared bots and scheduled
jobs that do not act as any one person. An owner adds it on /access
("Add person, group, or agent"), grants it folders like anyone else, and
mints its token there. The token is the only way it signs in, and it
always expires (1–366 days). What its profile card shows is exactly what
the token can reach — check it before you paste the token anywhere.
Interactive assistants keep acting as the person who runs them; both
models coexist.

## Reconciliation

Owners can ask the janitor to read one folder ("tidy…" on the folder
page): a model finds duplicates, contradictions, stale pages and
orphans, and argues each one. Nothing is automatic — findings become a
proposal branch in the normal review flow, a human merges, and git keeps
every deletion reversible. Contradictions are only ever flagged; no
model picks which fact is true.

## Synced folders

A workspace can keep a folder equal to a folder of another repository —
a codebase's `docs/`, or a whole repo — and it is two-way. The folder is
writable here like any other. Every workspace commit that touches it is
replayed onto the source's branch with its author, date and message
intact, and pushed; commits made in the source keep arriving here.
Comments ride along, since they are files in the folder (`.comments/`).
When both sides changed the same file the source stops, the row shows
`state: "conflict"` with the paths, the workspace is left untouched, and
an owner picks a side. Nothing is ever auto-resolved.

The server checks each source about once a minute. Every synced document
carries a "syncs with" chip naming its source and commit. Access rules
slice a synced folder like any other folder.

Connecting one is an **owner's** action, and you do it for them in two
steps, because a push needs a token that only the hosting platform can
mint and only your human should ever type:

1. Call the MCP tool `connect_source` (or `POST /api/agent/sources`
   below) **without a token** — just the repository URL, unless your
   human wants only one folder of it. The answer is `{"needs_token": true,
   "connect_url": "…"}` — the Sources page of *this* workspace (the link
   names it, so it lands in the right one even if your human is looking
   at another) with the repository, folder and destination filled in.
2. Open `connect_url` for your human, or hand them the link. On that page
   the only thing left is the token field; they paste a token that can
   push and click Connect. Tell them what the token needs: on GitHub,
   Settings → Developer settings → Fine-grained personal access tokens,
   scoped to that one repository, permission Contents: read and write,
   with an expiry. On GitLab: a project deploy token with
   `read_repository` and `write_repository`.

Never ask your human to paste the token into the conversation. If they
already handed you one, `token` may be passed and the connection is made
in one call.

```
POST /api/agent/sources
{"key": "td_…", "repo_url": "https://github.com/org/repo",
 "folder": "docs", "dest": "eng/repo-docs"}
```

`folder` is optional: leave it out (or pass `.`) and the whole
repository is the folder — the usual choice, and what the settings page
does unless "Advanced" is opened. `dest` is optional and defaults to the
folder's own name (or the repo's, for the whole repository); it must be a
folder that does not exist yet — a sync replaces its destination
wholesale on every pull, so connecting onto a folder that already holds
documents is refused rather than erasing them; pick another path, or
move those files into the source repo first. Destinations cannot nest
inside one another. A workspace can hold any number of sources, one per
codebase being the usual shape. The first sync runs before the call
returns, so a wrong URL, folder, branch or token fails right there.

`GET /api/agent/sources?key=…` lists what is connected with `state`
(`ok` | `conflict` | `error`), `conflicts`, `waiting` (commits here not
yet pushed) — stored tokens never come back, only `has_token`.
`POST /api/agent/sources/sync` (`{key, id?}`) runs a pass now;
`POST /api/agent/sources/update` (`{key, id, token}`) replaces a token
that expired or cannot push; `POST /api/agent/sources/resolve`
(`{key, id, keep: "ours" | "theirs"}`) settles a conflict — `ours`
pushes the workspace's folder over the source's head as one commit by
the resolver, `theirs` takes the source's folder in; the abandoned edits
stay in one history or the other. `POST /api/agent/sources/remove`
(`{key, id}`) disconnects, and the synced files leave with it; the source
repo is never touched by a disconnect. Thousand stores the token
server-side, uses it only for that source, and never echoes it back. A
push the token is not allowed to make (a protected branch, a read-only
token) shows up as the source's error, with git's reason.

Only whitelisted extensions surface in the app (documents plus assets),
so point a source at a folder of context files, not at `src/` — code
would sync into the repo but stay invisible.

**The whole workspace as a repository.** Pass `"dest": "."` (folder
empty) and the workspace itself is joined to the repository, root to
root. No clone, no replay: the workspace is a git repo, so the
repository becomes its remote — fetch, merge, push, with one shared
history and the same commit ids on both sides. An empty repository
receives the workspace whole; one that already has content is merged in
(unrelated histories are allowed — `welcome/` never collides). A real
git conflict pauses it like any source; `keep` merges with the chosen
side and pushes. Disconnecting keeps every document — only the link
goes. One root join per workspace. On the settings page this is the
"join this whole workspace" option under Advanced.

**"My repo is the real one."** For a person whose documents already live
in a git repo their agents maintain: join the workspace to that repo
(`dest: "."`) and the two are the same thing — edit in either, each
becomes commits in the other, and leaving Thousand costs nothing
because the repo already holds everything, comments included.

## Deleting things

Two kinds, and the line between them is reversibility.

**Documents and folders** are yours to delete when asked. Every deletion
is a commit, and the history page brings a document back, so there is
nothing to confirm:

```
POST /api/agent/delete
{"key": "td_…", "paths": ["notes/old.md", "drafts/"]}
```

A path ending in `/` takes the whole folder in one commit. Every file
under it must be inside your write access, or nothing goes. A connected
repository's folder is refused — disconnect it instead. The MCP tool is
`delete_document`.

**A workspace** is the one irreversible act, and you cannot finish it
yourself. `POST /api/agent/workspaces/delete` (`{key}`, owners only) or
the MCP tool `delete_workspace` answers with an **approval** (over MCP,
poll it with `poll_approval`):

```
{"needs_approval": true, "code": "XXXX-XXXX",
 "approval_url": "https://<host>/approve?code=XXXX-XXXX",
 "approval_secret": "tda_…", "interval": 3, "expires_in": 900}
```

Open `approval_url` for your human, or hand them the link, and show the
code, exactly as with device sign-in. On that page a signed-in **owner**
reads what will go, types the workspace name, and the deletion is
performed under their session — never under your token. Poll
`POST /api/agent/approvals/poll` `{"approval_secret": …}` every
`interval` seconds: `pending`, then `approved` (with `result`) or
`declined`. Codes live fifteen minutes. Never ask your human to paste
anything into the conversation; the page is the whole exchange. The
same shape will carry any irreversible act that arrives later.

## The tiers

Per folder: **editor** (read + write), **commenter** (read + comment — the
comment endpoint is this tier's entire write surface), **reader** (read).
**Owner** is workspace-level and governs the rules, not the contents.

## The exit

Nothing is locked in. A full-access clone of the canonical repo is the whole
workspace — markdown files, comments included — usable without Thousand,
forever. If you are asked to migrate a team out, `git clone` is the entire
procedure.

---
Revision e4ce60f9 · 2026-09-30. This page changes when Thousand does; re-fetch (ETag `e4ce60f9`) before acting on a reading more than a few minutes old.
