# Routines: developer manual

Everything you need to build an external runner for a routine in The Dump.
Last updated September 19, 2026.

This document is self-contained. You do not need repository access to use it.
Prefer plain text? [Read it as Markdown](/developer/routines.md) and paste it into your coding assistant.

---

## 1. What a routine is

A **routine** is a standing assignment: keep some useful document up to date
using a defined slice of a user's notes. The Dump holds the inputs, stores the
outputs, and shows the user what state things are in. **Your code does the
work.**

```
scoped notes  →  your runner (read, think, write)  →  canon docs
                                                   →  asks (questions for the owner)
                                                   →  scorecards (run reports)
```

Three things The Dump does **not** do:

- It does not schedule or execute your runner. You host it and decide when it runs.
- It does not keep history of the documents you publish. Only the latest copy is stored.
- It does not pay for or call any AI model. If you use one, that's your provider and your key.

## 2. Who does what

| Role | Does |
| --- | --- |
| **Routine owner** (a Dump user) | Creates the routine in the web app, picks which notes it can see, answers asks, reads the output. |
| **You** (runner developer) | Build and host the code. Sign in as the owner. Read notes, publish docs, post asks, report runs. |
| **The Dump** | Stores routines, notes, docs, asks, and scorecards. Computes status. Renders it all in the web app and iOS. |

## 3. Vocabulary

| Term | Meaning |
| --- | --- |
| **Scope** | The categories and/or subcategories whose notes the routine may read. Set at creation. |
| **Canon document** | A Markdown document (optionally with an interactive HTML view) that your runner publishes and maintains. The routine's "current understanding". |
| **Ask** | A question your runner sends to the owner when it needs a decision. The owner answers in the app. Your runner reads the answer and acts on it. |
| **Scorecard** | A structured report your runner sends after every run. The Dump turns these into a status chip and run history. |
| **Slug** | URL-safe identifier. Routines and documents each have one. |
| **Runner** | Your code plus whatever hosts and schedules it. |

---

## 4. Register the routine

The owner does this in the web app. Sign in at `https://thedump.ai`, then:

1. **Routines → Create routine** (`/routines/new`).
2. Enter a name and description, pick categories/subcategories, check the preview of matching notes.
3. Create. The app opens **Connect your runner** (`/routines/<slug>/connect`), which shows the generated slug, the API URLs, and copyable setup instructions.

The slug is generated from the name and is **immutable**. Name and scope cannot be edited after creation. To change scope, create a new routine.

A developer can also register via API:

```http
POST /api/routines
Content-Type: application/json

{
  "name": "Weekly project brief",
  "description": "Maintain decisions, open questions, and next steps.",
  "scope": {"category_ids": [1], "sub_category_ids": [12]},
  "request_id": "8140a64e-77a0-4712-b94b-f544fc56a286"
}
```

| Field | Rule |
| --- | --- |
| `name` | Required, 1–120 characters. |
| `description` | Optional, up to 4,000 characters. |
| `scope` | Required. `{category_ids, sub_category_ids}` (union, up to 500 IDs each) or `{"all_notes": true}`. Empty scope is rejected. |
| `request_id` | Optional UUID. Reuse it when retrying the same request; a replay returns the existing routine with 200 instead of creating another. |

Response `201` (or `200` on replay): `{routine, created, setup_url}`.
`GET /api/routines/scope-options` lists the account's category and subcategory IDs.
`POST /api/routines/preview` with `{scope}` returns `{count, notes}` so you can check a selection before creating.

---

## 5. Authentication

Every API call carries the **owner's** Firebase ID token:

```http
Authorization: Bearer <FIREBASE_ID_TOKEN>
```

There is no per-routine or per-developer credential. The token is the owner's
account-level token, so the owner supplies their credentials to your runner's
secret store, never to you in chat, source control, or logs.

Base URL: `https://thedump.ai`

Firebase web API key (public client config, safe to embed):

```text
AIzaSyDNqivcHgxiSgAfe289TqPD7e_gcP7z8dc
```

**Sign in** (email/password account):

```http
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=<WEB_API_KEY>
Content-Type: application/json

{"email": "<owner email>", "password": "<owner password>", "returnSecureToken": true}
```

Response: `{idToken, refreshToken, expiresIn}`. `expiresIn` is seconds (3600).

**Refresh** before expiry:

```http
POST https://securetoken.googleapis.com/v1/token?key=<WEB_API_KEY>
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=<stored refreshToken>
```

Response: `{id_token, refresh_token, expires_in}`. Store the newest refresh token. If refresh fails, the owner must sign in again.

Accounts that only use a federated provider (Google, Apple) must use that provider's Firebase sign-in flow instead of password sign-in.

---

## 6. Endpoint map

All routes require the bearer token. All bodies and responses are JSON.

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/routines` | List the owner's routines with status. |
| GET | `/api/routines/<slug>` | One routine: scope, status, document metadata. |
| PATCH | `/api/routines/<slug>` | Set `paused`, `next_expected`, `silence_threshold_hours`. |
| GET | `/api/routines/<slug>/notes` | Page through in-scope notes. |
| GET | `/api/note/<note_id>/asset` | Get a short-lived download URL for a note's original file. |
| GET | `/api/routines/<slug>/documents` | All canon docs (Markdown bodies; HTML only with `?include_interactive_html=true`). |
| GET | `/api/routines/<slug>/documents/<doc_slug>` | One canon doc, including `interactive_html`. |
| POST | `/api/routines/<slug>/documents` | Create or update a canon doc. |
| POST | `/api/routines/<slug>/asks` | Ask the owner a question. |
| GET | `/api/routines/<slug>/asks` | List asks. `?status=open` (default), `answered`, `applied`, `withdrawn`, `all`. |
| PATCH | `/api/routines/<slug>/asks/<ask_id>` | Mark an ask `applied` or `withdrawn`, optionally guarded by `expected_revision`. |
| POST | `/api/routines/<slug>/scorecards` | Report a run. |
| GET | `/api/routines/<slug>/scorecards` | Run history, newest first. `?limit=20` (max 200). |

Web pages the owner uses: `/routines`, `/routines/<slug>`, `/routines/<slug>/docs/<doc_slug>`, `/routines/<slug>/asks`.

---

## 7. Reading notes

```http
GET /api/routines/<slug>/notes?limit=100
```

Response: `{notes, next_cursor, count}`. `limit` defaults to 100, max 200.
If `next_cursor` is not null, URL-encode it and pass it as `since` on the next
request. Stop when it is null. The cursor is opaque; it is not a date.

**Each note:**

| Field | What it is |
| --- | --- |
| `note_id` | UUID. Your permanent reference. |
| `title` | Note title. |
| `body` | **The note as the user sees it now**, including their edits. Use this for what the note says. |
| `raw_content` | Text as originally captured or extracted (OCR, transcription). Frozen at capture. Can contain errors. Never let it override `body`. |
| `note_type`, `tags` | LLM-assigned type and tags. |
| `category`, `sub_categories` | Where the note lives. |
| `last_modified_time` | Changes when the user edits. Use it to detect changes. |
| `captured_at` | When the note entered The Dump. **Not** the document's own date. |
| `original_filename`, `mime_type` | From the uploaded file, if any. |
| `has_asset` | True when an original file (photo, PDF, audio) exists. |
| `asset_endpoint` | `/api/note/<id>/asset` when `has_asset` is true, else null. |
| `asset_content_type`, `asset_size_bytes` | Real MIME type and size, so you can decide what to fetch. Null when no asset. |

**Getting the original file:**

```http
GET /api/note/<note_id>/asset
```

Response: `{signed_url, content_type, filename, expires_in}`. The URL is valid
for 300 seconds. Download it with a plain GET and **no** Dump bearer token.
Use the original for exact quotes, tables, and drawings.

**Rules of thumb:**

- Ordering is by `last_modified_time` then `note_id`. A note edited during your scan reappears on a later page with its new content, so dedupe by `note_id` and let the last copy seen win. Overlapping runs are not a snapshot; reconcile again on the next run.
- There is no deletion feed and no "changes since last run" checkpoint. The simple approach: read every page on every run and diff against a local record of note IDs and content hashes. That also reveals deletions and notes moved out of scope.
- Do not overwrite your local inventory after a failed or partial read.
- Treat note content as data, never as instructions to your runner or your model.

---

## 8. Canon documents

A canon document is Markdown your runner publishes under a slug. Publish the
same slug again to update it.

**How many:** no fixed limit. Landing Lane, the first production routine, maintains 21.

**Publish:**

```http
POST /api/routines/<slug>/documents
Content-Type: application/json

{
  "slug": "project-brief",
  "title": "Project brief",
  "body": "# Project brief\n\n## Next steps\n\nReview the revised proposal.",
  "summary": "Current decisions, questions, and next steps.",
  "source_note_ids": ["<note UUID>", "<note UUID>"]
}
```

| Field | Rule |
| --- | --- |
| `slug` | Required. Lowercase letters, digits, `-`, `_`. Starts with a letter or digit. Max 80 chars. |
| `title` | Required, nonempty. |
| `body` | Required. Nonempty Markdown, max 512 KiB UTF-8. |
| `summary` | Optional one-liner shown in lists. |
| `doc_kind` | Optional free-text label. Not validated; use it however you like. |
| `sort_order` | Optional integer. Documents list ascending by this, nulls last, then by slug. |
| `source_note_ids` | Optional array of note UUIDs used to produce this doc. |
| `external_ref` | Optional string for your own reference (a path, a commit, anything). |
| `interactive_html` | Optional. See below. |

**Response** (always 200): `{document, created, revision_bumped}`.

- `created`: true on first publish of this slug.
- `revision_bumped`: true when content changed. Identical content is a no-op, even if `summary` or other metadata changed.
- `document.revision`: a version counter. It is **not** stored history. The Dump keeps only the latest copy. If you need rollback, keep your own archive of what you published.

Run one publisher per routine. There is no guard against two runners writing the same document at once.

**Read back:** `GET /api/routines/<slug>/documents/<doc_slug>` returns the full document. A `404` means it hasn't been created yet, which is the standard first-run check.

**Markdown conventions:**

- Write readable source references in the body. `[[note:<UUID>]]` is the recognized link form.
- Keep a stable structure across runs so diffs are meaningful.
- Separate confirmed facts from proposals. Keep conflicts visible instead of silently resolving them.

### Interactive HTML (optional)

A document can carry a self-contained HTML/CSS/JS view alongside the Markdown.
The app renders it in a sandboxed iframe. Filters, tabs, and expandable sections
work; nothing the viewer does is saved.

Add `interactive_html` to the same POST. Max 1 MiB UTF-8. Send it via a JSON serializer; do not hand-escape.

| You send | Result |
| --- | --- |
| HTML string | Replaces the view. |
| `null` | Removes the view. Markdown shows. |
| Omitted, Markdown unchanged | Existing view is kept. |
| Omitted, Markdown changed | Existing view is **cleared** so a stale visual can't contradict new text. |
| Omitted, new document | Markdown-only document. |

Always publish `body` and `interactive_html` together when either changes. An HTML-only change bumps `revision`.

**Authoring rules:**

- Everything inline. No remote scripts, styles, fonts, images, or API calls. They are blocked.
- No `eval`, `new Function`, or runtime package loading. Plain DOM and event listeners are fine.
- No forms, navigation, downloads, or anything that implies saving.
- Must work at phone width. Use native controls and visible focus states.
- The Markdown `body` must carry all the same information. Older clients and the MCP server only read Markdown.
- Escape note text before putting it in HTML or JS. Never include credentials.

Bulk `GET /documents` omits HTML unless you pass `?include_interactive_html=true`. After publishing, read back the single document and confirm both fields before reporting success.

---

## 9. Asks

An ask is a question with a recommendation. The owner answers in the app at
`/routines/<slug>/asks`. The Dump stores the answer. **Your runner carries it out.**

**Lifecycle:**

```
open ──(owner answers)──▶ answered ──(runner PATCH)──▶ applied
  └──(runner PATCH)──▶ withdrawn
```

**Create:**

```http
POST /api/routines/<slug>/asks
Content-Type: application/json

{
  "fingerprint": "proposal-comparison-2026-09",
  "title": "Should the revised proposal replace the earlier one?",
  "context": "The revised proposal excludes painting; the earlier one included it.",
  "recommendation": "Keep painting unresolved until the scope is confirmed.",
  "proposed_change": "Mark painting scope as awaiting confirmation in the plan.",
  "safe_default": "Leave the current plan unchanged."
}
```

| Field | Rule |
| --- | --- |
| `fingerprint` | Required. Any stable string unique to this decision within the routine. |
| `title` | Required. The question, as the owner sees it. |
| `context`, `recommendation`, `proposed_change`, `safe_default` | Optional but strongly recommended. Tell the owner what you found, what you suggest, what would change, and what happens if they ignore it. |
| `external_ask_id`, `batch_id` | Optional strings for your own tracking. |
| `supersedes_ask_id` | Optional UUID of an earlier ask this one replaces. |

Response: `{ask, created}`. Posting the same fingerprint again returns the
existing ask, including its answer if it has one, with `created: false`. So
retries are safe. Use a **new** fingerprint when the underlying decision changes.

**Ask object:**

| Field | Meaning |
| --- | --- |
| `ask_id` | UUID. |
| `status` | `open`, `answered`, `applied`, or `withdrawn`. |
| `answer_choice` | `approve`, `edit`, or `deny`. Null until answered. |
| `answer_text` | The owner's words, verbatim. Required when choice is `edit`; optional otherwise. |
| `answered_at`, `answered_by`, `applied_at` | Timestamps and who answered. |
| `revision` | Increments when the ask changes. |
| Plus everything you sent. | |

**Poll for answers:**

```http
GET /api/routines/<slug>/asks?status=answered
```

The default status filter is `open`, so pass `status=answered` explicitly.
Optional `since=<ISO-8601>` filters by `updated_at`.

**Handle an answer, then acknowledge it:**

1. Read `answer_choice` and `answer_text`.
2. `approve`: apply `proposed_change`. `edit`: apply the owner's wording from `answer_text`. `deny`: do not apply the change. Handle whatever follows from that decision.
3. Publish any resulting document updates.
4. Only then:

```http
PATCH /api/routines/<slug>/asks/<ask_id>
Content-Type: application/json

{"status": "applied", "expected_revision": 3}
```

`expected_revision` is optional. Send the `revision` you read on the ask, and
the call returns `409` with the current ask if the ask changed in between.
`applied` is only valid from `answered`; `withdrawn` is only valid from `open`.
Anything else returns `409` with the current ask. Re-sending the same status is
a harmless no-op.

Answers are immutable once given. The app never overwrites an answer, so the
answer you read is the answer that stands. Save your progress before the PATCH
so an interrupted run cannot apply the same answer twice.

---

## 10. Scorecards and status

After **every** run, including empty runs and failed runs, send a report:

```http
POST /api/routines/<slug>/scorecards
Content-Type: application/json

{
  "schema_version": 1,
  "fingerprint": "<sha256 of shift_id, 64 lowercase hex>",
  "shift_id": "run-2026-09-19T14:00:00Z",
  "overall": "OK",
  "trigger": "scheduled",
  "started_at": "2026-09-19T14:00:00Z",
  "ended_at": "2026-09-19T14:03:12Z",
  "steps": {
    "read_notes":  {"status": "done", "counts": {"pages": 3, "new": 4, "changed": 1}},
    "publish":     {"status": "done", "counts": {"documents": 2}},
    "asks":        {"status": "nothing-to-do"}
  },
  "reasons": ["4 new notes; brief and plan updated."]
}
```

| Field | Rule |
| --- | --- |
| `schema_version` | Required, must be `1`. |
| `fingerprint` | Required. 64 lowercase hex characters. SHA-256 of your run ID works. Dedup key: same fingerprint returns the stored report with 200. |
| `shift_id` | Required. Your unique run ID. |
| `overall` | Required. `OK`, `PARTIAL`, `FAILED`, `NOTHING_TO_DO`, or `UNKNOWN`. |
| `trigger` | Optional string. Convention: `manual`, `scheduled`, `arrival`. Defaults to `unknown`. |
| `started_at`, `ended_at` | Optional ISO-8601 with timezone. Naive is treated as UTC. More than 5 minutes in the future is rejected. |
| `steps` | Optional object. Each key is a step name you choose; value is `{status, counts?}`. Conventional statuses: `done`, `nothing-to-do`, `failed`, `partial`, `unknown`. Counts are free-form. |
| `reasons` | Optional list of strings, shown to the owner. |

Response: `201` with `{scorecard, created, alerted}` when new, `200` when the
fingerprint was already stored.

Be honest in `overall`. "I didn't edit anything" is not evidence that nothing
needed editing. A run that failed halfway is `PARTIAL` or `FAILED`, not `OK`.

**Status the owner sees.** `GET /api/routines` and `GET /api/routines/<slug>` include:

```json
"status": {
  "verdict": "OK",
  "as_of": "2026-09-19T14:03:12Z",
  "shift_id": "run-2026-09-19T14:00:00Z",
  "trigger": "scheduled",
  "reasons": ["4 new notes; brief and plan updated."],
  "open_asks": 1,
  "silent_for_hours": 2,
  "paused": false,
  "next_expected": "Every Monday morning"
},
"latest_scorecard": { ...the full report... }
```

Other keys may appear in `status`. Ignore ones you don't recognize.

`verdict` is computed on read, in this order:

1. `PAUSED` if monitoring is paused.
2. `UNKNOWN` if the routine is new and has never reported.
3. `SILENT` if the newest report is older than the silence threshold.
4. Otherwise the newest report's `overall`.

**Tell The Dump your schedule** so silence detection makes sense:

```http
PATCH /api/routines/<slug>
Content-Type: application/json

{"next_expected": "Every Monday at 6am ET", "silence_threshold_hours": 192}
```

Default threshold is 27 hours. A weekly runner should raise it before its first
report. `next_expected` is display text only. Neither field schedules anything.

The same PATCH takes `{"paused": true|false}`. **Your runner must check
`status.paused` (or `verdict == "PAUSED"`) at the start of every run and stop
if it is set.** The Dump does not stop you; it only records that you should stop.

A stored `PARTIAL`, `FAILED`, or `UNKNOWN` report raises an alert in the app.
An overdue report raises a silence alert. Alerts are in-app only; no email or push.

**Landing Lane's six-stage coverage card** is a special status extension built
for the first production routine. It is not available to new routines, and you
should not send its `coverage:landing-lane` or `source:imap:*` trigger values.

---

## 11. One run, end to end

1. Get a valid ID token (refresh if needed).
2. `GET /api/routines/<slug>`. Stop if `404` or `status.paused` is true. Note the document list.
3. Page through `GET /api/routines/<slug>/notes` until `next_cursor` is null. Diff against your local inventory.
4. `GET /api/routines/<slug>/asks?status=answered`. For each: act on it, publish results, `PATCH` it to `applied`.
5. For each changed or new note: fetch the original via `asset_endpoint` if you need it, and do the work.
6. Where a judgment call is needed, `POST` an ask with a stable fingerprint and leave the affected decision pending.
7. For each output document: `GET` the current version, compute the new one, `POST` if changed, `GET` again to confirm.
8. `POST` a scorecard describing what happened.
9. Persist your local state (inventory, cursors, what you published, which asks you handled).

Do a manual dry run first, look at the output in the web app, then turn on the schedule.

---

## 12. Errors, limits, retries

| Code | Meaning | Do |
| --- | --- | --- |
| 400 | Bad request. The `error` field says what. | Fix it. Do not retry unchanged. |
| 401 | Token expired or invalid. | Refresh the token and retry once. |
| 404 | Routine, document, or ask not found for this account. | Check the slug and the signed-in account. |
| 409 | Ask state conflict, or a `request_id` reused with different content or a different owner. | Read the returned object and reconcile. |
| 413 | Document `body` over 512 KiB or `interactive_html` over 1 MiB. | Shrink it. |
| 429 | Rate limited. | Back off. Honor `Retry-After` if present. |
| 500 / 503 | Server or storage failure. | Retry with bounded backoff. Keep the payload; scorecards and documents are idempotent. |

Rate limit: 60 requests per minute per endpoint per client IP. Pace pagination.

API calls must come from a server, computer, or worker. Browser JavaScript on
another site is blocked by CORS.

Registering a routine never authorizes external actions. Sending email, making
purchases, or booking things is outside what the routine grants.

---

## 13. Alternative: the MCP server

Owners who use Claude Desktop or similar can read routines through The Dump's
MCP server (`the-dump-mcp`). Its `list_routines`, `get_routine_document`, and
`list_asks` tools are read-only and Markdown-only. They are for humans checking
on a routine, not for building a runner.
