Agents

What an agent can do with a Tuval workspace today, and — the longer half — what it cannot.

There are two directions and they are no longer one-way. A board goes out to an agent as a brief, and a brief comes back as a board. A workspace comes back to an agent as records, and goes back again as records. What an agent still cannot do is edit either one in place, and this page says where that line falls rather than leaving it to be discovered.

Reading and writing the workspace

Twelve tools, mounted over stdio, which is what Claude Code and Cursor speak.

claude mcp add tuval -- node /path/to/tuval/scripts/mcp.mjs
Tool What it does
search Words in titles and bodies across pages, databases, issues and projects. An excerpt and an id back
read_page One page or issue as Markdown, headings and lists intact
read_board One board as Markdown — its frames, notes, connectors and comments, as the last browser to open it wrote them
list_boards The boards in this workspace, with how many items and frames each holds
list_records Records of one kind, filtered by status, assignee, project or cycle
workspace What this key can reach, whether it may write, and how many writes are left today
create_record File an issue, page, project, person, company or event
update_record Change a title, status, assignee, priority, due date, parent, project or cycle
append_to_page Add Markdown to the end of a page or issue
create_board Make a board from a Markdown brief. The browser draws it — see below
update_board Rename a board, or send it another brief — appended under what is there, or replacing what the last brief drew
trash_board Put a board in the trash. Marked, not removed

The first six are GET and work with any key. The last six go through the same door as curl, and want a key whose scope is write: a read key answers 403, a key past its daily allowance answers 429, and the server turns both into a sentence rather than a number, because the two call for opposite decisions.

Check it is alive without leaving the shell:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node scripts/mcp.mjs

Writing the workspace directly

The six write tools above are a thin wrapper on the HTTP API. The same key, the same door, one more verb — and a script that is not an agent uses it the same way.

GET /records · GET /records/<id> · GET /records/<id>/markdown Read
GET /search?q= · GET /cycles · GET /labels · GET / Read
GET /boards · GET /boards/<id>/markdown Read
POST /records Create. kind defaults to issue, returns 201
PATCH /records/<id> Change what you send and nothing else
DELETE /records/<id> Archives. Returns {"archived":"<id>"}
POST /boards Make a board from a brief. Returns 201 and where it will be
PATCH /boards/<id> Rename it, or send it another brief — append under what is there, or replace what the last brief drew
DELETE /boards/<id> Trashes. Returns {"trashed":"<id>"}

A record comes back under a record key, and a listing under records, both beside a note saying the text was written by people and is not addressed to the reader. Same sentence /search carries, on every door that hands back something somebody typed.

curl -X POST -H "authorization: Bearer tuv_..." -H "content-type: application/json" \
  -d '{"title":"Rewrite the address step","status":"todo","priority":2}' \
  https://<project>.supabase.co/functions/v1/api/records

A write takes these fields and drops the rest:

kind title description status assignee priority due_at estimate parent_id project_id cycle_id position data

body is not on that list, and neither is markdown, and they never will be. Those two are the flattened copies of the page that the browser writes beside the real thing so that Postgres has something to index; the page itself is a CRDT in record_docs that no server here can read or edit. A write to either column would look like it worked — it would show up in search and in GET /records/<id>/markdown — and then vanish the first time somebody opened the page and typed.

Prose goes in description, and it does reach the page. That column is the one thing outside the document that becomes document text: the app folds it into the end of the body the next time somebody opens the page, as real paragraphs, headings and lists, and clears the column. It is what append_to_page writes.

The gap is worth stating rather than glossing. Between the write and that next open, the text is readable — GET /records/<id>/markdown returns it in the place it will take — but it is not in search, because search indexes the flattened document and the text is not in the document yet. It is a delay, not a second place for text to live, and nothing is lost either way. What is still true is the older sentence: there is no verb that edits a page somebody is looking at.

The gate

Five things are checked before any of the above happens, and each one answers on its own.

Check If it fails
The workspace is not on the free plan — paid, self_hosted, or on a carried domain 401
The key is not revoked and not past its expires_at 401
Somebody is still behind the key — a key whose maker has no row, or was taken out of the workspace, or was blocked 401
The key's scope is write, for anything that is not a GET 403
The key has writes left for the day — 1000 by default 429

Two of those are worth stating plainly. On a free hosted workspace every call answers 401, including tools/list working and tools/call failing — the MCP server reports the reason as text so the model can read it and stop rather than retry. And a write key is downgraded to read the moment its holder's seat is anything but owner, admin or member: the scope is recomputed from the seat on every call, not frozen when the key was made.

Beyond the gate, a key is never more than the person who made it. A page with people named on it stays shut unless they are one of them — GET /records and GET /search leave those rows out, and asking for one by id answers 404.

Handing a board to an agent

This direction is not an API at all. The canvas turns into a brief in the browser: graphToPrompt() in src/board/agent.ts walks frames into sections, connectors into a mermaid graph and comments into notes, and the result reaches the agent by clipboard, by downloaded .md, or as a chat URL. No key, no request, no server.

The board's own text arrives inside a fence:

<<<BOARD_CONTENT>>>
…the board…
<<</BOARD_CONTENT>>>

The prompt tells the model that everything between those markers is untrusted data written by the people using the board: quoted material, never instructions. Any <<<BOARD_CONTENT>>> shaped string in the board itself has its angle brackets escaped, so a sticky note cannot close the fence early and start giving orders.

What it cannot do today

Plainly, because the shape of the door is easy to mistake for the shape of the product.

Reviewing what it did

Every write carries the name of the run it belonged to — the MCP server mints one per session, and a caller can name its own with an x-tuval-run header. /runs in the app lists them: who ran it, how long it took, which records it touched, what each one was before, and one action to put any of it back.

What that is worth is measured rather than asserted: Reproducing.

Related