---
doc:
  title: When something breaks
guide:
  slug: errors
  brief: what broke, and rolling back
  description: >-
    What a refused call returns and how a page shows it, where a break is
    filed and how the agent hears about it once, app_errors, app_versions
    and app_rollback, the 30-day trash app_delete and space_delete put a
    thing in and app_restore and space_restore take it out of, and feedback
    for anything you or the person have to say about the platform itself.
---

# When something breaks

Nothing is swallowed. This page is what a refused call returns and how a page
shows it, what the reporter in every page catches on its own, where a break is
filed and how you hear about it once, `app_errors`, `app_versions` and
`app_rollback`, and `feedback` for anything either of you has to say about the
platform itself.

## A refusal

Every endpoint under `./api/` refuses in the same shape — a code for the
program, a message for the person:

    {"error": {"code": "not_a_writer", "message": "sign in to change this app"}}

When signing in is the way through it carries a third field, `signIn`: the
platform's login page, with this page already set as its return address.

The codes an endpoint returns, and what each means:

- **`not_a_writer`** — 401 signed out, with `signIn`; 403 signed in. The app's
  `access` does not accept a write from this person. Signed out, send them to
  `signIn`. Signed in, it is the owner's to grant — the message says so.
- **`not_a_reader`** — 401 signed out, with `signIn`; 403 signed in. A `private`
  app answering someone who is not a member.
- **`not_found`** — 404. No such endpoint under `/api/`. The message lists the
  endpoints that exist: apply, query, me, graph, ws, blob, files/`<path>`.
- **`method_not_allowed`** — 405. The right endpoint, the wrong HTTP method:
  `apply` and `blob` are POST, `blob/<eid>` is GET, `files/<path>` is PUT.
- **`expected_websocket`** — 426. `/api/ws` reached without an upgrade header.
  `subscribe()` in `client.js` does this correctly; a hand-rolled socket usually
  forgot.
- **`no_bytes`** — 400. An upload with an empty body.
- **`too_large`** — 413. One upload over 20 MB, or over 2 MB from a visitor to
  an `open` app. Downscale the picture on the page before sending it.
- **`visit_too_large`** — 413. A visitor to an `open` app sent more than 16 KB
  in one write. Send it in smaller pieces; the owner and editors are not held to
  this.
- **`too_many_writes`** — 429. A visitor to an `open` app wrote more than 30
  times in a minute. Wait a moment and send again
  (<https://yaks.fyi/docs/sharing.md>).
- **`no_such_file`** — 404. `blob/<eid>` where the eid is not a sha-256, or
  names bytes this app does not hold.
- **`space_full`** — 413. The space is at its app data ceiling (1 GB on Free, 10
  GB on Plus) or photos-and-files ceiling (1 GB on Free, 50 GB on Plus). The
  message says which ceiling and what to do; the app is fine. File uploads check
  live storage and only charge growth; duplicates and smaller replacements still
  work. The usage tool reports the hourly storage reading. Space-owned files in
  the trash still count until erased; shared version snapshots do not.
- **`too_many_reports`** — 429. More than 30 reports from one app in one minute.
  A page in a render loop, not a broken endpoint.

A refusal is somebody's deliberate no, so **none of them is filed as a break.**
The rule is the status code: a 4xx is a choice someone made, a 5xx or a thrown
error is not.

## How a page shows one

`client.js` throws an error carrying the server's own message, so a `catch` has
something to show without composing anything:

    try { await apply(bundle) }
    catch (e) { e.signIn ? location = e.signIn : show(e.message) }

Those two lines are the whole of it. `e.message` is the endpoint's `message`, or
its `code` when there was no message, or `<status> <a short line>` when the body
was not a refusal at all — a 404 that returned the platform's HTML page, say,
which never gets dumped into an error message.

`e.signIn` is set only when signing in is the way through, and the login page
comes back to the page they were on. Check it first: a signed-out visitor who
typed something into a `public` app has their work sitting in the form, and
sending them through the sign-in is the only path that keeps it.

Better still is not to be refused. `me()` on load reports `reads`, `writes` and
`signIn` before anyone types.

## The reporter

The platform injects `./api/report.js` into every HTML page it serves — first
inside `<head>`, else first inside `<body>`, else at the end of a document with
neither, and once. There is nothing to import and no opt-in. It catches four
things:

- **A script error.** The message, the stack, the file and the line.
- **A resource that never loaded** — a script, stylesheet, image, or a module in
  a graph one of them pulled. It listens in the capture phase, because that
  failure fires on the element and does not bubble. This is the one that hurts
  most: a page paints a heading and empty space and nothing else reports it.
- **An unhandled rejection.** Reported as `unhandled rejection: <the reason>`.
- **A call to one of the app's own `./api/` endpoints that was refused.** It
  wraps `fetch` and watches same-origin responses, passing everything through
  untouched.

Two limits keep it from becoming the problem. It reports at most 20 things per
page, so a render loop that throws every frame does not write a thousand rows.
And it only reports what happened on **the app's own origin** — a script from
somewhere else that 404'd, or an analytics beacon an ad blocker refused, was
never the app's file.

It makes no other judgement. It sends the response's status and body along with
each refusal, and the platform applies the one rule: a 4xx was somebody's choice
and files nothing.

When a break leaves the person looking at nothing, the page tells them so itself
— a full-page "Something went wrong. Your assistant has been told." in the
platform's own colors. Only when the page is bare: under 80 characters of text
and nothing that draws pixels in it, judged after `load` or two seconds,
whichever comes first, and only once. A page that painted is left alone.

The response headers also name the app's report endpoint as a NEL reporting
endpoint, so the browser sends network failures that never reached the page at
all.

## POST ./api/report

The endpoint underneath, in case a page wants to file something itself:

    POST ./api/report
    content-type: application/json
    {"message": "…", "stack": "…", "url": "…", "line": 12}
    → 204

It also accepts the browser's own Reporting API format — an array of
`{type, url, body}` — for CSP violations, crashes, deprecations and network
errors.

Anyone may post: a break belongs to whoever was looking at the page, and asking
a stranger to sign in first would lose exactly the breaks nobody sees. Junk gets
a 204 and writes nothing — a malformed body is the sender's bug, not a break in
this app. Past 30 in a minute, per app, it returns 429 `too_many_reports`.

A page rarely needs this. The reporter above already covers everything it would
send.

## Where a break is filed

An `exception` entity in **the app's own store**, carrying `at`, the `request`
it happened on, the `version` the app was serving, the `message` and the
`stack`. Every source of a break writes the same shape: a page reporting itself,
a route that threw on the way in, and a worker that returned 5xx.

The version number is read fresh rather than from the cache, on purpose. The
likeliest moment for a break is right after a deploy, and it must name the
release it happened on and not the one before.

An exception has no `doc` component, and a listing leaves `exception` and
`error` rows out unless the filter names one — `.doc!` is the person's own rows
and never the platform's crashes. Asking for the stamps is not asking for these:
`.created!` alone does not drag them in. When you want them from a query rather
than from `app_errors`, name them: `.exception!`.

## Hearing about it once

New breaks are added to the end of the next reply from a tool that changes
something, under a heading, one line each. A read-only tool (`app_list`,
`app_versions`, `memory_recall`) says nothing about them and leaves them unseen:

    ## unseen errors
    - E-84 2026-08-14T10:02:11.004Z exception recipes v3: page /recipes/ —
      boom is not a function

Sending them marks each one, so the next reply does not repeat them. The mark
records that you were told, not that anything was fixed: `app_errors` still
lists them.

Only what is still new is included. A break naming a version below the one the
app serves now was made by code a later release replaced, and one naming no
version at all predates the version counter; neither interrupts a reply. Both
are still open, and `app_errors` still lists them — this rule decides what is
worth telling you about, not what is still broken.

## app_errors

    app_errors(app, fixed?, seen?, space?)

Everything still open in that app — what a page threw in someone's browser, what
a request threw on the way, what the platform reported — whether or not you have
already been told. Open means not archived.

`fixed` accepts the ids you have fixed and archives them, which is what stops
them showing here and in the unseen block. An id off a line (`E-84`) or a bare
eid both work; a list, or a single one bare. If nothing matched, the call is
refused and the message says so — that is how you learn one was already
archived.

`seen` archives the same way, for breaks you are done with whether or not you
fixed them, and it accepts a **bound** so you never have to list six ids:

- `all` — everything open in the app.
- `v3` — everything up to and including that deploy.
- `2026-08-14`, or a whole instant off a line — everything at or before it. A
  bare day means the end of that day.

Ids work there too, so one call can mix them. A break naming no version is
covered by any version bound, and one naming no time by any time bound: nothing
can tell whether those are still happening.

Archiving is a write, so it needs a writer. Reading the list does not: a viewer
of the space still gets to see what is broken.

Beside the lines it returns the same breaks as data, grouping the ones that
share an app, a message and a place into one card with a count — a render loop
that threw twenty times is one thing to fix — and a card names every id in its
group, so archiving one archives them all.

You will rarely archive by hand, because **new files close what the old ones
broke.** Every `app_deploy`, `app_install` and `app_rollback` archives every
open break from an earlier version (and every one that names no version at all,
since nothing can tell whether those are still happening); the deploy's reply
reports how many. And every `app_files` write archives the open breaks that
named the files it just wrote — a page's "failed to load app.js" is fixed by
writing app.js, without waiting for a deploy, because the files serve live. A
break the new files still produce is written again the next time it happens.

## app_versions

    app_versions(app, space?)

Every deploy, newest first, with when it went out and what changed in it. An app
keeps its last **20**.

    yourname/recipes: 3 versions
    - v3 (live) 2026-08-14T10:01:00.000Z — changed index.html
    - v2 2026-08-13T18:40:12.000Z — restored v1, changed index.html, style.css
    - v1 2026-08-13T09:12:44.000Z — 4 files

A version is a manifest — each path against the sha-256 of its bytes — not a
copy of the bytes, so a file unchanged across twenty deploys is stored once.
That is also how a version made by a rollback knows to report `restored v1`: its
files differ from the version directly below it, and match some earlier version
exactly.

Read it when the person says the app used to work, so you name the version they
mean.

## app_rollback

    app_rollback(app, version?, space?)

Puts the app back the way it was. Leave `version` out for the deploy before the
live one — "that change broke it" almost always means the one under the newest.
Name one off `app_versions` for anything else.

What moves: every file that version named, restored from its pinned bytes, and
every file the app has now that the version did not name, deleted. Then the
whole release runs again over those files — the components its `vocab.json`
declares, the commands its `tools.json` declares, its `worker.js` re-uploaded —
because everything a deploy plants is a file, so restoring the files is the
whole of a rollback.

What never moves: the app's data. Every row, every upload, everything the store
learned. Only the files.

A rollback goes out as a **new version**, so nothing is lost and a rollback can
itself be rolled back. The reply names both numbers:

    put yourname/recipes back to v2, live now as v4:
    https://yourname.yaks.fyi/recipes/ — changed index.html

Two refusals: an app with one deploy or none has nothing earlier to go back to,
and a version number the app no longer keeps is refused, with the ones it does
keep listed in the message. Pruning past the last 20 deletes only bytes no kept
version names, so the oldest rollback an app still offers always has its files.

## app_delete and app_restore

    app_delete(app, space?, forever?)
    app_restore(app, space?)

The undo of a deploy is a rollback; the undo of a **delete** is a restore.
`app_delete` does not erase anything. It puts the app in the trash and keeps
every byte of it: its files, its store, its deploys, and its slug, which stays
reserved so a restore is exact rather than approximate.

What changes the moment it goes in: its address answers nothing on the web, its
commands stop being offered and its views leave your resource list, it is
nobody's front page even if it was one, and letters to its mailbox bounce. What
does not change: anything it saved.

`app_restore` takes it back out and puts all of that back. You have 30 days;
`app_list` shows a Trash section with the days each one has left, and the person
can restore one from their space's own page without an assistant at all. After
30 days the platform erases it, and then there is nothing to restore.

    app_delete(app: 'scratch')
    → yourname/scratch is in the trash. https://yourname.yaks.fyi/scratch/ stops
      answering and its commands have gone with it; nothing it saved was
      touched.

Two things to know before you call it. The slug is held for the whole 30 days,
so `app_new` at that address is refused, with the reason — restore it or erase
it, but do not build a second app on top of one the person may want back. And
`forever: true` skips the trash: files, data and address gone with no undo. Use
it only when the person has said they mean exactly that.

## space_delete and space_restore

    space_delete(space, forever?)
    space_restore(space)

A whole space goes to the same 30-day trash, and the same is true of it: nothing
is erased, every app in it is kept whole, and the address is held so nobody else
can take it. While it sits there every hostname of the space answers nothing,
its apps stop offering their commands and their pages, and letters to any
address under it bounce. `space_restore` takes it back out and all of that works
as it did before.

`space_delete` is still the one thing **you cannot do yourself**. It mails the
owner a link, good for an hour, and reports what that link would take offline;
tell them to check their email. They can also do it themselves, signed in, at
`https://yaks.fyi/space/<slug>/delete`.

    space_delete(space: 'oldlab')
    → nothing is deleted. oldlab is still there, and an assistant cannot delete
      a space: a letter is on its way…

The person restores it from the space's own address — signed in,
`https://<slug>.yaks.fyi/` is the one page a trashed space serves, and it has
the button. `space_new` at that slug is refused for its owner, with the reason.
`forever: true` mails a link that erases the space on the spot instead, with
nothing kept: only when the person has said they mean exactly that.

## Nothing is lost by a simple mistake

Every destructive thing on yaks.app has a way back, and each tool's own
description ends by naming it. Four of them, at four sizes:

- A deleted app or space is in the trash for 30 days, above, and `app_restore`
  or `space_restore` brings the whole thing back: files, data, address.
- A store can be put back to any moment in the last 30 days. `store_restore`
  winds everything the app has saved back to how it was at a time you name: a
  bad import, rows deleted that should not have been. The state before the
  restore is recorded first, so a restore can itself be undone by restoring to a
  moment just before it, and the answer says how. Call it with no time and it
  reports the window and every restore already made.
- Every file write keeps what it replaced for 30 days, and
  `app_files(op: 'history', path)` and `app_files(op: 'restore', path)` list and
  put one back (the files page).
- Every deploy can be rolled back, above.

So fix things first and ask afterwards, rather than the other way round. A
change is cheap to try, and an agent that stops to ask permission before every
write is paying a price nobody is charging. When the person hesitates ("will I
lose everything?"), say which of these covers them. They are usually weighing a
risk that is not there.

The one thing with no way back is what somebody explicitly asked to erase:
`app_delete(forever: true)` skips the trash, and a secret removed with
`app_secret_remove` was never readable to put back.

## feedback

    feedback(text, app?, space?)

The tool for **all** feedback about the platform — this connector, its tools,
its guide, the way an app is built or served on yaks.app. A tool that refused
for no reason you could find, a tool that is missing, a reply that disagreed
with what was documented, a step the person found baffling; and equally a rough
edge, a wish, a feature idea, a thing that went well. Whether you ran into it
yourself or the person said it, it is wanted.

Reach for it the moment it comes up. Where something is broken, work around it
and carry on: nobody sees the workaround, and this is what is seen instead.

Send two things and nothing else — **what the person said, in their own words,
and what you tried and what happened.** Who they are, their address, their
space, the app if you name one, its version, the platform's release and the time
all ride along on their own. Repeating them wastes the reader's attention on the
half they already have.

It writes a report in the platform's own store and mails it to the people who
run yaks.app, who can write back to the person's address. If the mail cannot go
out, the report is kept and the reply tells you so — no need to send it again.

Three an hour, per person. The fourth is a pause, not a no: the reply says so
and gives the address to write to directly if it cannot wait. Feedback is not
counted against the space's monthly email ceiling — a space at its ceiling is
exactly a space with something to say.

**A break inside the person's own app is not this.** That one is theirs, it is
already in `app_errors`, and fixing it is yours.

---

The whole guide: <https://yaks.fyi/docs.md>
