---
title: "Run Comment.io workspace commands"
description: "Once connected, an agent works in a Comment.io workspace with shell commands over its documents: read, search, write and suggest edits, keep typed records, and copy files. The examples use SSH; over MCP or the HTTP API the same commands run with the same output and exit codes. Connecting is in the guide index."
canonical: "https://comment.io/llms/commands.md"
last-updated: 2026-10-12
---

# Run Comment.io workspace commands

Once connected, an agent works in a Comment.io workspace with shell commands over its documents: read, search, write and suggest edits, keep typed records, and copy files. The examples use SSH; over MCP or the HTTP API the same commands run with the same output and exit codes. Connecting is in [the guide index](https://comment.io/llms.txt).

## Run workspace commands

Comment.io runs shell scripts over workspace documents. Start with `help`: it
covers the everyday commands (search, edit, comment, suggest) and names, for
each other group of commands, the built-in skill with its details, which
`skill view NAME` prints.
Quote the remote script so your local shell does not expand it. Pipes, `;`,
`&&`, `||`, variables, globbing, and redirection are supported. Documents live
in `/workspace`, the initial working directory; `ls`, `ls .`, and
`ls /workspace` list that directory. Other paths belong to a temporary virtual
filesystem. The document mount is a restricted writable virtual workspace.
Explicit document commands handle namespace changes; `>`, `>>`, `tee`, and
`sed -i` commit each existing-document write when that operation is encountered.
The host filesystem, arbitrary programs, and network remain unavailable.

Run `doc-*` mutations directly. A request is either one standalone mutating
`doc-*` command or one shell script containing writes to existing documents.
Mixing explicit mutations with shell writes is rejected, but shell writes that
were already encountered remain committed. Successful mutations produce no
command output, so read back the document separately. There is no caller request
ID or replay token. If an SSH result is uncertain, read the latest state before
deciding what to do next; repeating a mutation is new work and may duplicate its
effect.

Every file lives in a Project: a top-level folder holding its Home
(`index.html` or `index.md`), `definitions/`, `things/`, `apps/`, which holds
only its other HTML pages (`.html`), and `files/`, where all other files and
folders go, but no new HTML pages. Definitions and Things that every Project
uses belong in the Common Project; a workspace may still have `shared/`, which
every Project sees too until it becomes part of the Common Project, after which
a `shared/` path fails with `shared_retired`, naming where it is now. `skills/`
holds the workspace's skills. `scratch/` holds a workspace's files from before Projects until
they are sorted: read, edit, move out or remove them, but nothing can be
created or moved into it. A text `style.css` may also sit at the workspace root
or directly in each Project. A write anywhere else fails with `layout_refused`
and a line saying where it can go. `doc-create crm/index.md` starts the
Project `crm`.

An owner can limit a Project to the people they add, so a Project you were
working in can vanish mid-task. You have the access of the person you work
for: a Project they aren't in appears nowhere and reads as a missing path; ask
an owner to add them. As a Viewer you read files without their comments; as a
Commenter you also read, add and resolve comments, suggest, and withdraw your
own suggestions; any other change fails with `project_read_only`. Your SSH
key or token reaches no further than the person who gave it to you, and only
the person you work for can give you a new one.
`projects` prints one JSON line per Project you can open, with your level
there (`edit`, `comment` or `view`), and `"limitedByGiver":true` where the
person who gave your key or token holds you below your own level. The one
marked `"common":true` is the Common Project: everyone in the workspace can
open it, and it can't be restricted, renamed, moved or deleted. Find it by that
mark, not by its name; a workspace with none marked has no Common Project yet.
To keep something from some people, use another Project.
`My Space` is the Project of the person you work for that only they and the
workspace's owners can open; address it by that name, as in
`My Space/files/notes.md`.

```sh
ssh -n WORKSPACE.AGENT 'rg -il needle -g "*.md" | sort | head -10'
printf '# Notes\n' | ssh WORKSPACE.AGENT 'doc-create crm/files/notes.md --stdin'
printf '\nNext: send the deck.\n' | ssh WORKSPACE.AGENT 'doc-append crm/files/notes.md --stdin'
ssh -n WORKSPACE.AGENT "doc-replace-text crm/files/notes.md Notes 'Meeting notes'"
ssh -n WORKSPACE.AGENT 'doc-read crm/files/notes.md crm/index.md'
```

`doc-read` takes up to 20 paths and prints a JSON line for each, in order. A
file that fails prints its error on stderr, the rest still read, and the
command exits 1. Once it has printed 16 MiB, the paths left fail with
`read_limit_exceeded`; read them in another call.

An uploaded file (a PDF, image, Office document, audio or video) is a text
file to agents: `doc-read`, `cat`, `head`, `sed` and the other utilities read
its extracted text, `rg` searches it under the file's own path, and
`rg --files` lists it. A transcript has a line per sentence, with its
speaker and start–end time; one made before October 2026 times each word
instead. Download the file for its original bytes; writes to it fail with
`binary_file`. Extracted text counts toward the workspace's search size limit.

```sh
ssh -n WORKSPACE.AGENT 'rg -n -i "budget" -g "*.mp4"'
ssh -n WORKSPACE.AGENT 'head -20 crm/files/meeting.mp4'
ssh -n WORKSPACE.AGENT 'doc-read crm/files/contract.pdf'
```

Until its text is ready, the file matches nothing and a read fails with one
of: `text_pending` (on its way; try again in a few minutes), `text_disabled`
(an owner must turn on file text extraction in workspace settings),
`text_unsupported` (no text is extracted from this type) or `text_failed`
with the reason, such as `media_too_long` or `no_audio_track`. Each read sees
one version of the text; text over 16 MiB cannot be read or searched. A shell
command prints at most 2,000,000 bytes, so `cat` fails on longer text; use
`doc-read`, or print part of it with `sed -n` or `rg`.

To create a document from a local Markdown file, or append one, without
quoting its contents:

```sh
ssh WORKSPACE.AGENT 'doc-create crm/files/notes.md --stdin' < local.md
ssh WORKSPACE.AGENT 'doc-append crm/files/notes.md --stdin' < more.md
ssh -n WORKSPACE.AGENT 'doc-read crm/files/notes.md'
```

`doc-create --stdin` creates the file and its text together: if the text is
refused (a Thing that fails its Definition, a limit), no file is left behind.

`--stdin` supplies the text for one create, append, anchored insert or
replacement; `doc-replace-text PATH ANCHOR --stdin` takes its replacement from
stdin, so multi-line text needs no quoting. Input preserves Unicode, quotes,
backticks, and trailing newlines. Invalid UTF-8 or a NUL character fails. Empty input fails for append and
insert; to `doc-replace-text` it deletes the anchor, and to `doc-create` it
makes an empty file (a skill, Thing or Definition starts from its template). Finite requests collect at most 2,000,000 bytes and require
EOF within 10 seconds, before any command executes. Oversized, timed-out, or
interrupted input commits nothing. Use `ssh -n` when no stdin is intended;
SSH clients using an API must end their input stream. Bare SSH, `help`, and
standalone `watch` do not wait for stdin. This is a finite shell, not an
interactive terminal. A request may contain only one stdin-consuming document
operation; this prevents buffered input from being consumed twice. Once an
operation commits, a later disconnect does not undo it. Read back the committed
document after an uncertain result; do not automatically retry a mutation.

**When a human disconnects an agent:**

Every current human member can Disconnect an agent in Team; agents cannot grant
or revoke access themselves. This revokes all of that agent's keys, not its
identity or document/notification history. A new SSH connection fails
authentication; a newly admitted exec using a revoked key fails with
`error: unauthorized` and exit 1. Draining connections reject new channels.
Live streams and transports stop within 30 seconds under a running process.
Database failure or pool starvation beyond the validity lease also closes
transports; `unavailable` does not mean a human revoked access.

Already-admitted database writes may finish after the transport closes, even
beyond 30 seconds. Forced closure may return an error/exit 1 or SSH transport-loss
exit 255; it does **not** prove rollback. Never automatically replay a mutation.
Ask a human or another authorized identity to inspect persisted state before
deciding what new work is needed. A replacement agent has a new principal, so
old receipt/request IDs do not deduplicate work across handles.

Reconnecting requires a new, unused agent handle and a new human-approved
enrollment request. Retrying the completed old enrollment does not restore keys.
Disconnect cannot recall output the agent already received.

**Agents do not need to know version numbers.** `doc-read` returns only
`{path, content}`. Neither you nor the command handles document revision tokens,
snapshots, or CRDT updates; the backend owns those details. Enrollment and access
recovery credentials ([Connect over SSH](https://comment.io/llms/ssh.md)) are separate from document editing.

```text
doc-read <path>...
doc-create <path> [--folder | --stdin]
doc-append [--suggest] <path> --stdin
doc-insert [--suggest] <path> (--before|--after) <anchor> [--occurrence N] --stdin
doc-delete-text [--suggest] <path> <anchor> [--occurrence N]
doc-replace-text [--suggest] <path> <anchor> [--occurrence N] (<replacement> | --stdin)
```

With `--suggest`, right after the command name, an edit to a Markdown file is
proposed as a suggestion for a person to accept or reject instead of changing
the text; `help` describes its limits and errors.

Each edit uses the document state captured by the backend when that edit
executes, after complete stdin has been received and validated where required.
An earlier `doc-read` does not pin a later edit; there is no hidden last-read or
session edit base. Anchors resolve exactly in the captured text. Insert, delete, and
replace require a unique anchor unless a one-based `--occurrence N` is supplied.
Append inserts at the captured text's end. `doc-insert` adds stdin directly
before or after the anchor text itself, not on a new line; include any newline
you need in stdin, for example `printf '\nReviewer: bot'` with
`--after "Meeting notes"`.
Whitespace, line endings, combining sequences, and Unicode are significant;
anchors are not regexes and are not normalized.

Edits committed before capture are already in the base. Edits arriving after
capture merge into the latest state of that same file; overlapping replacements
have no prose-level winner guarantee. A capture follows that file through
rename/move. Removal retires the captured identity and fails with
`stale_document_identity`; removal/recreation or reuse of its old path cannot
redirect an existing capture to a different file. A path missing when capture
starts fails with `file_not_found path="notes.md"`, naming the resolved path
JSON-quoted; `doc-rename` adds `destination="..."`, since either the source or
the destination's folder may be missing.
`doc-rename` preserves the document's text identity, and `doc-remove` removes
its path. Create refuses existing paths; rename refuses existing destinations.
A rename that would let more people see the file or folder, with its history
and comments (into a Project more people can open, or out of My Space), fails
with `move_widens_access`, naming who; if they should see it, run
`doc-rename <path> <destination> --widen`.
Renaming a Definition (`P/definitions/task.md`) to another Project or file name
(`Q/definitions/task.md`) moves its type as one unit: every Thing in
`P/things/task/` and every file under `P/files/` they use, with their comments,
history and connections; missing folders are made. Moving `P/things/task/`
itself is refused with `thing_folder_move`.

`doc-remove <path>` accepts a file or empty folder. Removing a non-empty folder
requires `doc-remove <path> --recursive`; it atomically removes the whole live
subtree. Inspect the subtree first. A concurrent change to the folder's path can
return `file_busy` with no removal committed; inspect again before explicitly
retrying. Deletion retains the entries' last parent/name,
content and history but hides them from live reads and releases text quota.
Recreating the same path creates a different identity. Retained parent/name is
not a historical full path when an ancestor is subsequently renamed.

`cp`, `mv`, and `rm` cannot mutate shared documents, and shell
redirection/`tee`/`sed -i` can only write existing documents; to upload a whole
file, including a new one, use `scp` or `sftp` (below). Use temporary paths for intermediate shell output.
Token-free `doc-*` edits do not change shell `sed`/`awk` read/write behavior or
`>` truncation semantics. A shell write is not pinned to an earlier read; a
whole-file rewrite can overwrite changes already present at its write capture.
A failure before a write commits nothing; writes encountered before a later
failure are not rolled back. Pipelines use `pipefail` by default. Use `&&` or
`set -e` to stop a sequence after other failures.
`watch` emits `ready` and then live-only file/folder change hints while the
standalone SSH command remains connected; it sends no initial worktree or replay.
A `folder.created` hint can stand for a whole folder a person brought back by
undoing its delete, contents included; list the folder to see them.
Native `rg` searches run within
`/workspace`. Network access and arbitrary host programs are unavailable.

**Your name.** People see your display name in comments, Team and the
directory; it starts as your handle. `name` prints your handle and display name
and the workspace's address and name; `name set NAME` renames you as a member
renames themselves in Settings. Your handle, SSH alias and access stay the
same. Quote a name with spaces twice over SSH. Only a workspace owner renames
the workspace, in Settings.

```sh
ssh -n WORKSPACE.AGENT name
ssh -n WORKSPACE.AGENT 'name set "Research Assistant"'
```

**Your picture.** People see your picture in place of your initial. Send a
static PNG, at most 1024×1024 pixels and 512 KiB, directly to storage. Run
`avatar upload SIZE SHA256` over SSH (SHA256 is the base64-encoded SHA-256 of
the PNG); send the PNG to the returned URL with its method and signed headers,
then run `avatar complete UPLOAD_ID`. Do not send image bytes through SSH or
MCP or `/api/run`, or log the presigned URL. Metadata and animation chunks are
refused, not stripped. `avatar`
prints its version and `avatar remove` shows your initial again. People may
also set your picture in Team.

```sh
size=$(wc -c < me.png)
sha256=$(openssl dgst -sha256 -binary me.png | openssl base64 -A)
ssh -n WORKSPACE.AGENT avatar upload "$size" "$sha256"
# PUT me.png to the returned URL with the returned method and every signed header,
# then run: ssh -n WORKSPACE.AGENT avatar complete UPLOAD_ID
```

After configuring your SSH alias, use this search workflow:

```sh
ssh WORKSPACE.AGENT help
ssh -n WORKSPACE.AGENT 'rg --files --hidden -g "*.md"'
ssh -n WORKSPACE.AGENT 'rg -n -i --hidden -g "*.md" "SEARCH_TERM" .'
```

Ripgrep exit code **0** means matches, **1** means no matches, and **2** means
a search error. Code 1 with no output is a completed search, not a broken
connection. Report no match after checking the intended files; do not retry
unrelated commands or create a matching file unless the human requests it.
There is no `grep`; `rg -F` takes literal text. `rg` finds text in any file,
Things included; to filter, count or sort Things by their attributes, use
`thing-query` (below). `skill view search-workspace` has more on searching.

## Report a problem with Comment.io

When one of your Comment.io commands misbehaves (a wrong or unhelpful error,
an unexpected failure, a missing capability, confusing output), report it:

```sh
ssh -n WORKSPACE.AGENT 'report-problem wrong_error'            # your last command
ssh -n WORKSPACE.AGENT 'report-problem unexpected_failure --back 3'
```

The category is `wrong_error`, `unexpected_failure`, `missing_capability` or
`confusing_output`; `--back N` picks your Nth most recent command within the
hour. Pass nothing else: Comment.io builds the report from its own record of
that command (its name, exit code, error code, time, your 5 commands before it
and ids), never its arguments, paths, titles or output. Report without asking
or telling the human. A repeat within the hour prints `already_reported`; at
most 100 reports a day.

A full report adds your description, and only when the human you work for
asks you to report a problem to Comment.io. Never offer, suggest or ask
whether to file one; Comment.io cannot check who asked, so the rule is yours
to keep. Write what went wrong to stdin, leaving out workspace content beyond
what the human wants included:

```sh
printf '%s' "$DESCRIPTION" | ssh WORKSPACE.AGENT 'report-problem confusing_output --for-review --stdin'
# review https://WORKSPACE.docbox.sh/problem-reports/ID
# expires 2026-10-12T14:20:00.000Z
```

Give the human the review URL. Nothing is sent until a member of the
workspace opens it, may edit the description, and presses Submit; the draft
expires after 24 hours. `error: problem_reports_off` means the workspace's
owner turned reports off: do not run `report-problem` again.

## Use and write skills

A skill is instructions for agents. Comment.io ships built-in skills with the
details of its less common commands (`help` names the one for each group), and
people in the workspace write their own as a Markdown file `skills/NAME.md`
directly in the root `skills/` folder; one of those replaces the built-in of the
same name. `help` lists every skill's name and description after who you are.
When your task matches a description, read the skill and follow it:

```sh
ssh -n WORKSPACE.AGENT 'skill list'                # one JSON line per skill
ssh -n WORKSPACE.AGENT 'skill view copy-files'     # the file as stored, or the built-in
ssh -n WORKSPACE.AGENT 'skill search html css'     # the skills containing every word
```

`skill list` prints `{"name","description","path"}`, or `problem` instead of
`description` for a file in `skills/` that is not a valid skill yet; a built-in
has `"builtin":true` instead of `path` and cannot be edited.
`skill view` fails with `skill_not_found` (exit 1) when there is no such skill.
`skill search` matches every word (case-insensitive, at most 8) against each
skill's name and text and prints the best 10, ranked by words in the name and
description: `skill list`'s line plus `lines`, up to 3 lines of its text that
contain a word. It reads at most 20 matching workspace skills and says on stderr
when more match; it exits 0 when nothing matches. All three are standalone
commands, not usable in scripts.

To write one, run `skill view write-a-skill` and follow it:
`doc-create skills/NAME.md --stdin` creates it whole from the complete skill on
stdin (without `--stdin` it starts from a template). NAME is lowercase
letters, digits and single hyphens. The frontmatter needs `description:`, what
the skill does and when to use it, in at most 1,024 characters; `name:`, when
present, equals NAME; other keys are kept. An invalid write is refused as
`thing_invalid` and nothing is saved. Renaming the file updates its `name:`.
Files in subfolders of `skills/` are ordinary files that a skill may point to.

## Keep typed records (Things)

A Thing is a Markdown file whose YAML frontmatter makes it a typed row, such as
an investor, a person or a support ticket. A Definition at
`PROJECT/definitions/STEM.md` names the type and its attributes (every Project
sees the Common Project's types); its Things live in
`PROJECT/things/STEM/KEY.md`. Every write to a
Definition or Thing, whether a command, `scp`, a shell write or a person's
edit, is checked whole and refused when invalid, with nothing saved.

Use the four standalone commands, not scripts and not create-then-append, which
stays for ordinary files. Each prints one JSON line:

```sh
# 1. Create the type. Attribute types: string, number, boolean, date
#    (YYYY-MM-DD), or another Definition's title, which makes a connection.
printf '%s\n' '{"path":"crm/definitions/person.md","definition":{"title":"Person","attributes":{"Email":{"type":"string","one":true,"required":true}}}}' |
  ssh WORKSPACE.AGENT 'definition-set --stdin'
# 2. Create a Thing (a draft at crm/things/person/jon-doe.md).
printf '%s\n' '{"type":"Person","name":"Jon Doe","set":{"email":"jon@example.com"}}' |
  ssh WORKSPACE.AGENT 'thing-set --stdin'
# 3. Change only the listed keys; null clears one.
printf '%s\n' '{"path":"crm/things/person/jon-doe.md","set":{"review":"confirmed"}}' |
  ssh WORKSPACE.AGENT 'thing-set --stdin'
# 4. Read back.
ssh -n WORKSPACE.AGENT 'thing-get crm/things/person/jon-doe.md'
ssh -n WORKSPACE.AGENT thing-types
```

- An attribute's key, used in frontmatter and in `set`, is its name lowercased,
  with each run of other characters as `_`: `Next action` is `next_action`.
- `one: true` allows one value; otherwise the attribute is a list.
  `required: true` needs a value once a Thing is `confirmed`; drafts may leave
  it empty. `values` lists the only allowed strings.
- `show: always` puts an attribute at the top of every Thing of the type in
  the browser; the others (`show: expanded`, the default) show once the
  Thing's properties are expanded; a file attribute's files show under the
  properties either way. It changes no Thing. An attribute sent
  without `show` keeps its current one, through `renamed` too; a new one is
  `expanded`.
- A connection's value is the target Thing's key (`{"owner":"jon-doe"}`).
- To replace a Definition, send the `revision` that `thing-get` printed for it.
  A stale one fails with `definition_changed`. `"renamed":{"Old":"New"}` moves
  values to the new name, and a changed type converts them. A value that no
  longer fits refuses the change unless `"clear_unconvertible":true` clears it.
  One change rewrites at most 1,000 Things.

A refusal prints its code on the first line of stderr and exits 1, followed by
one JSON problem per line, `{"path","attribute","message"}`, whose message
says what to fix. The codes are `thing_invalid`, `definition_invalid`,
`thing_linked`, `definition_in_use`, `thing_folder_move`, `definition_changed`,
`definition_change_too_large`, `not_a_thing` and `type_move_too_large`
(nothing moved; retry). Malformed JSON input is
`invalid_input`, exit 2.

## Query Things

Things are Markdown files whose frontmatter makes them typed rows of a type,
such as Investor. `thing-query` runs one read-only SQL `SELECT` over one type
and prints one JSON object per row; every non-aggregate row includes `path`,
which you can pass to `doc-read`.

```sh
ssh -n WORKSPACE.AGENT "thing-query \"SELECT name, stage, owner.name FROM Investor WHERE stage = 'lead' ORDER BY name LIMIT 20\""
printf '%s' "SELECT stage, count(*) FROM Investor GROUP BY stage" | ssh WORKSPACE.AGENT thing-query --stdin
```

- `FROM` names one type title; quote a title with spaces: `FROM "Support ticket"`.
- Columns are attribute keys and `type`, `key`, `name`, `review` and `path`.
  `owner.name` follows the connection `owner` one hop, and
  `owner.manager.name` follows `owner`, then the target's `manager`. A column
  follows at most 3 connections, and a query with a longer path than `a.b` at
  most 8 in all, counting each column every time it is written. A test on a
  path holds when some chain of links reaches a passing value, so a missing
  link makes it false.
- Conditions use `= != <> < <= > >=`, `[NOT] IN`, `[NOT] LIKE`,
  `IS [NOT] NULL`, `AND`, `OR`, `NOT` and parentheses. Numbers compare as
  numbers and dates as dates (`'2026-10-12'`, `today() + 7`, in UTC). On a list
  attribute a comparison matches when any value does; `IS NULL` means no value.
- Aggregates are `count(*)`, `sum` and `avg` over a number attribute, and `min`
  and `max` over a number or date, alone or with `GROUP BY`, for example
  `SELECT stage, count(*), sum(check_size) FROM Investor GROUP BY stage`.
  `ORDER BY` takes them or their aliases. Each value of a list counts, a Thing
  without a value adds nothing, and a fold over no values is `null`.
- Without `ORDER BY`, rows come in key order. `LIMIT` defaults to 100 and is at
  most 1,000; page with `OFFSET`. A result over 8 MiB fails rather than
  truncating.
- A refusal exits 1 with its code on the first stderr line and one JSON problem
  per line after it: `query_invalid` names the token and the fix,
  `query_timeout` means the query ran over 2 seconds, and `query_busy` means
  no slot came free in about half a second (a workspace runs one query at a
  time, and the rest wait briefly): retry in a moment.

### Live Things in an HTML page

A workspace `.html` page can query and change Things itself, so a dashboard you
write stays current without being rewritten and can act on what it shows. The
app runs each request as the person viewing the page. The page never sees a
session or token.

To style rendered HTML, create optional text files named `style.css` at the
workspace root and at the Project root (`crm/style.css`). Comment.io loads the
workspace CSS first and the Project CSS second for every HTML page in that
Project, including pages in `apps/`. Equal-specificity Project rules override
workspace rules; styles written in the HTML page come last. Missing files add
no CSS. Buttons, text fields and dropdowns start as plain white pills in the
page's font, so they need no CSS; any rule you write for them wins. If the
optional CSS read fails, the page still opens without it; the next render tries
again. CSS edits appear when the HTML is rendered again; reopen a page to see a
CSS-only change.
These files are private; the app reads them into the isolated page frame. Its
`color-scheme` follows the viewer's Light/Dark choice (or device under
System), even on an open page. Use `light-dark(light, dark)` for colors that
should follow it; set both foreground and background together. An explicit
CSS color or `color-scheme` remains yours. An unstyled page stays on white
paper. `@media (prefers-color-scheme: dark)` follows the device preference,
not a manual Light/Dark choice in Comment.io's isolated frame.

For a new stylesheet, use `doc-create style.css --stdin` (or
`doc-create crm/style.css --stdin`) with the CSS on stdin, then
`doc-read style.css` to check the saved text. Read before retrying a write whose outcome is uncertain. For example:

```css
:root {
  font-family: system-ui, sans-serif;
  color: light-dark(#242424, #eee);
  background: light-dark(#fff, #171717);
}
table { border-collapse: collapse; }
td, th { padding: .5rem; border-bottom: 1px solid light-dark(#ddd, #555); }
```

```html
<table><tbody id="deals"></tbody></table>
<script>
  const href = (path) => "/" + path.split("/").map(encodeURIComponent).join("/");
  const stop = commentio.things.watch(
    "SELECT name, stage, owner.name FROM Investor ORDER BY name",
    (rows) => {
      document.getElementById("deals").replaceChildren(...rows.map((row) => {
        const tr = document.createElement("tr"), td = document.createElement("td"), a = document.createElement("a");
        a.href = href(row.path); a.textContent = row.name;
        td.append(a); tr.append(td);
        return tr;
      }));
    },
    (error) => console.warn(error.code, error.message));
  commentio.things.query("SELECT count(*) FROM Investor").then((rows) => console.log(rows[0]["count(*)"]));
</script>
```

- A click on a workspace link in the page opens its file in place of the page.
  To open it beside the page instead, so the page stays in view, put
  `data-open="reference"` on the `<a>` (or `<area>`) or any element around it.
  The nearest `data-open` of `primary` or `reference` wins (other values are
  ignored): `<body data-open="reference">` covers the whole page, and
  `data-open="primary"` opts one link back out. ⌘ or Ctrl and a
  click opens a new tab, and links to other sites always do.
- `commentio.things.query(text)` resolves to the rows `thing-query` prints,
  including `path` on non-aggregate rows.
- `commentio.things.watch(text, onRows, onError)` calls `onRows` now and again
  when the rows change. It reruns about 300 ms after a write to Things of the
  queried type or of a type on its paths, and when the tab comes back.
  `stop()` ends it. A page has at most 32 watches.
- A failure is an `Error` with `code`: `query_invalid` (also for text over
  16 KiB), `query_timeout`, `query_busy`, `watch_limit` or `query_failed`.
  A watch retries `query_busy` by itself; retry a one-off `query` yourself.
- A link to `"/" + path` opens the Thing in the app, as in the example.
- `commentio.things.set(input)` takes `thing-set`'s input, `{path, set}` or
  `{type, name, set}` (which creates the Thing), and resolves to the Thing as
  `thing-get` prints it:

  ```js
  box.onchange = () => commentio.things
    .set({ path: "tasks/things/todo/renew-passport.md", set: { done: box.checked } })
    .catch(() => { box.checked = !box.checked; });
  ```

  A failure is an `Error` with `code`, `problems` and `message`: `thing-set`'s
  codes, `invalid_input`, `write_busy` or `write_limit` (a page writes at most
  20 Things at once, then 1 a second; nothing was saved), or `write_failed`
  (it may have been saved). A write is never retried for you. The app already
  tells the viewer why a write failed, in its own error message, so the page
  need not show one: undo what it showed, as above.
- When the workspace owner turns on **Ask before pages change Things** in
  Settings, a write is refused `page_write_approval_required` until the viewer
  allows this version of the page, and `page_write_needs_input` unless it
  starts from a click or key press in the page, with none on the rest of the
  app in the last 5 seconds. Write from event handlers, not on load.
- Any third-party script the page loads can read the rows and write too, so
  load only scripts you trust.

Agents have no push API: to follow changes, run `watch` (path hints under
`definitions/` and `things/`) and rerun `thing-query` when one arrives.

## Copy files with scp and sftp

Ordinary OpenSSH `scp` and `sftp` work against the same alias, in both
directions. Remote paths are relative to `/workspace`, with the same path rules
as other commands; nothing outside the workspace is reachable.

```sh
scp notes.md WORKSPACE.AGENT:assets/
scp -r site WORKSPACE.AGENT:
scp WORKSPACE.AGENT:assets/notes.md .
sftp WORKSPACE.AGENT    # ls, get, put, mkdir, get -r, put -r
```

- SSH uploads accept UTF-8 text of at most 2,000,000 bytes. Binary names and
  non-text bytes fail with `unsupported_file_type`; oversized text fails with
  `file_too_large`. Missing folders are created, and existing document text is
  replaced as with `>`.
- To upload an image, PDF, Office or OpenDocument file, audio or video, use the
  direct-to-S3 agent API rather than SSH. For example, with an API token kept in a file:
  `node scripts/upload-binary.mjs https://docbox.sh report.docx notes/report.docx TOKEN_FILE`.
  Only those kinds are stored, by their extension; anything else (`.exe`, `.bin`,
  a `.zip` outside a Project's `files/`) fails with `unsupported_file_type`. A
  `.zip` is accepted in a Project's `files/` only to be expanded: as its upload
  completes, into a new folder named after it (numbered `-2` and on if taken
  there or in the Project's `apps/`), its HTML pages going to the same place in
  `apps/`, then removed; the script waits and prints the folder. Other
  files' original bytes are retained. Text extraction runs unless the workspace's
  owner turned it off; it sends documents and images to OpenAI, and audio and
  video soundtracks to AssemblyAI.
- A file commits when its upload ends: a dropped connection or an interrupted
  scp commits nothing; a file closed by the sftp client commits what arrived.
  Ctrl-C inside interactive `sftp` closes the file. If a new document's text
  then fails to commit (for example at the workspace text limit), the new
  document remains, empty.
- Downloads read committed document text or retained binary bytes.
- Remove and rename with `doc-remove` and `doc-rename`; `sftp` `rm` and
  `rename` are unsupported.
- `scp -O`, and `scp` from OpenSSH before 9.0, use the older scp protocol,
  which works the same way with three exceptions. Copying several files into
  a folder needs the folder to exist (`not_a_folder`); create it first with
  `doc-create PATH --folder`. It cannot expand a remote wildcard
  (`unsupported_path`); name each file. It cannot send a name containing a line
  break (`unsupported_name`). Plain `scp` and `sftp` have none of these limits.

Each failure prints `error: CODE ... path="..."` on stderr, as other commands
do. The OpenSSH client adds its own generic line after it. `file_busy` means
the file changed while it was being saved and nothing was saved; retry.
