workbench Docs

Operating

Jots file store

Deploy a self-contained static site from an agent and serve it at /j/<name>/, public or password-gated.

A jot is a static web artifact the workbench hosts for you: a report, a chart, a small tool, a page an agent built. You deploy a directory as a gzip tarball and it is served at /j/<name>/.

Jots are a built-in integration (auth.type: "none"), so they are always connected and need no credential. They live in server source rather than the plugin directory, because their handlers write to the filesystem — a capability the plugin context deliberately does not expose.

The five tools#

ToolDoes
deploy_jotValidates the name and returns an upload URL plus a single-use token. Publishes the whole site
update_jotSame, in patch mode: the archive is overlaid onto the live site instead of replacing it
list_jot_filesLists the files inside a jot you own — path, bytes, updatedAt
list_jotsLists your jots — name, access, url, updatedAt, newest first
delete_jotDeletes a jot you own; FORBIDDEN for another owner, NOT_FOUND if absent

Deploying#

Deployment is two steps: mint, then upload.

Call deploy_jot#

json
{ "executions": [ { "tool": "deploy_jot",
  "args": { "name": "release-report", "access": "public" } } ] }
json
{
  "result": {
    "uploadUrl": "https://workbench.example.com/j/upload/9f2c…",
    "token": "9f2c…",
    "expiresAt": 1756400300000,
    "maxBytes": 5242880
  }
}

Upload the tarball#

Package the directory's contents — the archive root must be the site root — and POST the gzip stream:

bash
tar czf - -C ./site . \
  | curl --data-binary @- \
      -H 'Content-Type: application/gzip' \
      https://workbench.example.com/j/upload/9f2c…

Open it#

text
https://workbench.example.com/j/release-report/

/j/release-report without the trailing slash redirects to it. Directories fall back to index.html.

The upload replaces any previous deploy of that name wholesale — this is a publish, not a merge. To change one file without re-uploading the site, use update_jot instead.

Updating one file#

deploy_jot republishes everything. When only one file changes — a data.json regenerated weekly, a chart's dataset, a config blob — update_jot overlays your archive onto the live tree and leaves the rest alone.

See what is there#

json
{ "executions": [ { "tool": "list_jot_files",
  "args": { "name": "release-report" } } ] }
json
{
  "result": {
    "files": [
      { "path": "data.json", "bytes": 4120, "updatedAt": "2026-08-24T00:05:00.000Z" },
      { "path": "index.html", "bytes": 18233, "updatedAt": "2026-08-24T00:05:00.000Z" }
    ]
  }
}

Owner-scoped, like list_jots. The internal manifest is never listed.

Mint a patch token#

json
{ "executions": [ { "tool": "update_jot",
  "args": { "name": "release-report" } } ] }

Returns the same { uploadUrl, token, expiresAt, maxBytes } shape as deploy_jot.

Upload only what changed#

bash
tar czf - -C ./site data.json \
  | curl --data-binary @- \
      -H 'Content-Type: application/gzip' \
      https://workbench.example.com/j/upload/3a71…

Pass delete to remove paths — a directory removes its contents:

json
{ "executions": [ { "tool": "update_jot",
  "args": { "name": "release-report", "delete": ["last-quarter", "stale.json"] } } ] }

Deletes are applied to the staged copy before the archive is overlaid, so a path that is both deleted and uploaded keeps the uploaded version.

Two differences from a deploy are worth holding onto:

  • No index.html needed in the archive. The live one is retained, so a patch that ships only data.json is fine. A deploy in the same shape would be rejected with NO_INDEX.
  • Access is inherited by the upload. access and the password hash are read from the live jot rather than from the token, so the archive you upload can never change who can read a jot. To change that, pass the settings to update_jot itself — see below. Redeploy for that.

Ownership is re-checked when the upload lands, not just at mint: a jot that has been deleted meanwhile returns 404, and one that changed hands returns 403.

The merged tree is re-measured against both limits. JOTS_MAX_BYTES and JOTS_MAX_FILES are enforced during extraction, which only sees the archive — so without this check a run of small patches could walk a jot past either cap. A patch that would push it over returns 413 (TOO_LARGE or TOO_MANY_FILES) and leaves the live jot untouched.

Changing access, password, or CORS#

Those three settings do not need an upload at all. Pass them to update_jot and they are written to the live jot as soon as the call returns:

json
{ "executions": [ { "tool": "update_jot",
  "args": { "name": "release-report", "access": "password", "password": "hunter2" } } ] }
json
{ "result": { "uploadUrl": "…", "token": "…", "expiresAt": 1756000000000,
  "applied": { "access": "password", "cors": false } } }

The applied block is the confirmation that the change is already live. The uploadUrl comes back as always, for file changes in the same call; ignore it and it simply expires.

  • Lock a jot: { "access": "password", "password": "…" }, or just { "password": "…" }.
  • Unlock it: { "access": "public" }. The stored hash is dropped.
  • Rotate the password: pass a new password. Restating access: "password" without one keeps the current password.
  • Toggle CORS: { "cors": true } or { "cors": false }.

Anything you leave out keeps its current value, and the files are untouched either way.

Changing a password signs everyone out

A visitor's unlock cookie is an HMAC over the current password hash, so rotating the password — or unlocking the jot and gating it again — invalidates every cookie already issued for it. Whoever still needs in has to enter the new password.

The guards, in order#

deploy_jot checks three things before minting anything, and returns on the first failure.

OrderErrorCause
1INVALID_NAMEThe name is not ^[a-z0-9][a-z0-9-]*$, or is longer than 64 characters
2PASSWORD_REQUIREDaccess: "password" was requested without a password
3JOT_NAME_TAKENThe name exists and belongs to a different user
Jot names are global and creator-locked

There is no per-user namespace. report is one name across the whole server, owned by whoever deployed it first. The original owner can redeploy it freely. Everyone else gets JOT_NAME_TAKEN and cannot overwrite or delete it. On a shared workbench, prefix your names.

The upload token#

The token returned by deploy_jot is single use and expires after JOTS_UPLOAD_TTL_SECONDS (default 300). The upload endpoint takes no other authentication: the token is the credential. A consumed, unknown, or expired token returns 404, and expiresAt is epoch milliseconds.

The token itself is an opaque random handle and carries nothing. The pending deploy — owner, name, mode, gating, and delete list — is a row in the database, so the token survives a restart and is visible to every worker and replica; nothing here needs sticky routing. For a password jot the row holds the already-hashed password, and the password itself is hashed at mint and never travels with the upload.

Single use is enforced by the database, not by process memory: consuming deletes the row and only the caller whose DELETE actually removed it is served, so two concurrent uploads of one token cannot both succeed — on any worker, in any replica. Abandoned rows are swept once past their TTL.

Because the deploy lives in a row rather than in the URL, the delete list a single update_jot can carry is not bounded by URL length.

Upload failures#

StatusBodyCause
404Unknown or already-consumed token
413{"error":"TOO_LARGE"}Decompressed size over JOTS_MAX_BYTES
413{"error":"TOO_MANY_FILES"}More than JOTS_MAX_FILES entries
400{"error":"BAD_ARCHIVE"}Not a readable gzip tarball
400{"error":"NO_INDEX"}No index.html at the archive root
409{"error":"JOT_NAME_TAKEN"}The name was claimed between mint and upload
500{"error":"DEPLOY_FAILED"}The publish step failed

NO_INDEX is the one that catches people. The archive root must contain index.html directly — that is what /j/<name>/ serves. Archiving the directory itself instead of its contents produces site/index.html at the root and a rejected upload. Note the -C ./site . in the command above: it is doing the work.

Password-protected jots#

json
{ "executions": [ { "tool": "deploy_jot",
  "args": { "name": "internal-notes", "access": "password", "password": "…" } } ] }

A locked jot behaves differently by client:

  • A browser navigation gets HTTP 200 and an unlock page. Submitting the password sets an httpOnly, SameSite=Lax cookie scoped to that jot, good for 30 days, and redirects into the site. Production deployments mark it Secure.
  • Anything else — a curl, a fetch — gets 401 Unauthorized.

A wrong password re-renders the unlock page with an error and 401.

This is access control for casual sharing, not a secrets store. Anyone with the URL and the password reads the content.

How jots are served#

Every jot response carries a restrictive header set. The important one:

text
Content-Security-Policy: sandbox allow-scripts allow-forms

That puts each jot on an opaque origin. Scripts run, forms submit, but the page cannot read the workbench's cookies or make credentialed same-origin requests to /api or /mcp. Responses also carry nosniff, X-Frame-Options: SAMEORIGIN, and Cross-Origin-Resource-Policy: same-origin.

By default, a jot cannot fetch its own data files

The opaque origin means same-origin fetch from inside a jot does not work. Pages must be self-contained: inline the data, or embed it as a data URI. Loading data.json alongside index.html at runtime will fail. This is the single most common surprise when deploying a page that worked locally — see cors below for the opt-out.

Letting a jot read its own files#

Pass cors: true on deploy_jot or update_jot to serve a public jot's files with Access-Control-Allow-Origin: * and Cross-Origin-Resource-Policy: cross-origin, and to answer preflight OPTIONS requests. That is what makes fetch('./data.json') work from inside the page.

json
{ "executions": [ { "tool": "deploy_jot",
  "args": { "name": "weekly-metrics", "access": "public", "cors": true } } ] }

This pairs naturally with update_jot: the page ships once, and the weekly data file is patched in on its own. update_jot can also flip the flag on a jot that is already live — cors: true to turn it on, cors: false to turn it back off — with no upload.

What it does not do is weaken the sandbox. sandbox allow-scripts allow-forms, nosniff, and X-Frame-Options are unchanged, so the page still cannot reach app cookies, storage, /api, or /mcp.

`cors: true` makes a jot's files world-readable by other sites

Any origin can read them. A public jot is already readable over a plain GET, so this only matters if you were treating an unguessable jot name as a secret — which it never was.

On a password jot the flag is stored but ignored: an opaque-origin fetch carries no cookie, so the request would be answered with 401 regardless. Preflight requests to a jot without cors return 404, so a jot's CORS posture cannot be discovered by probing.

Two more serving details:

  • The internal manifest file is never served. A request for it returns 404, not 403, so its existence is not confirmed.
  • Path traversal out of the jot directory returns 403 Forbidden.

Limits and configuration#

The defaults are stated in the tool's own description, but all four are configurable.

VariableDefaultRequiredPurpose
JOTS_DIR<dirname of DATABASE_URL>/jotsnoWhere jot directories live
JOTS_MAX_BYTES5242880 (5 MiB)noDecompressed size cap
JOTS_MAX_FILES1000noFile-count cap, enforced during extraction
JOTS_UPLOAD_TTL_SECONDS300noLifetime of a mint token

The size and file caps are enforced while extracting, not after, so a zip-bomb-shaped archive is stopped partway rather than after filling the disk. A patch is measured twice: once during extraction, and again on the merged tree, because extraction only ever sees the incoming archive.

JOTS_DIR defaulting next to the database means jots and tokens.db share a volume by default. On a small volume, point it somewhere with room.

Listing and deleting#

json
{ "executions": [ { "tool": "list_jots", "args": {} } ] }
json
{
  "result": {
    "jots": [
      { "name": "release-report", "access": "public",
        "url": "https://workbench.example.com/j/release-report/",
        "updatedAt": "2026-08-30T09:12:44.000Z" }
    ]
  }
}

Only your own jots are returned — there is no way to enumerate another user's, even though the names share one namespace. In-flight staging directories are skipped.

json
{ "executions": [ { "tool": "delete_jot", "args": { "name": "release-report" } } ] }

Returns {"ok": true}, or {"error":"FORBIDDEN"} / {"error":"NOT_FOUND"}. Deletion is immediate and removes the directory. The name becomes available again.