Herald

REST API

Plain HTTP endpoints for automations, scripts and agents that don't speak MCP.

Updated

The REST API gives you the same context as the MCP tools, over ordinary HTTPS requests. It's the simplest way to add your workspace to an n8n or Zapier workflow, a Custom GPT, or your own code.

Basics

Base URL: https://getherald.ai/api/v1

Authentication: an API key in the Authorization header. The key decides the workspace, so there's no workspace id in any path.

Authorization: Bearer cmcp_...

Format: JSON in and out, unless you ask /context for Markdown.

OpenAPI spec: https://getherald.ai/api/v1/openapi.json. Import it into anything that understands OpenAPI, including Custom GPT Actions.

A personal key gets its creator's private notes as well as the team's pages, marked "private": true. A workspace key gets shared pages only. A key limited to some folders sees nothing outside them.

POST /context

The main endpoint. Send a task, get back the rules and relevant context for it. The same as the get_context MCP tool; see How get_context chooses.

Body

Field Type
task string, required What you're about to do, in plain words. At least 3 characters.
max_tokens integer Budget from 1,000 to 30,000. Default 12,000.
pack string Pack name to apply, instead of matching packs to the task.
tags string[] Only search sections with these tags.
folder string Only take material from this folder and its subfolders, like Market/Battlecards. Rules from anywhere still apply. Returns 404 if there's no such folder.
format string json (default) or markdown.

Example

curl -X POST https://getherald.ai/api/v1/context \
  -H "Authorization: Bearer $CONTEXT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"task": "Reply to a customer who was charged twice after downgrading", "max_tokens": 6000}'

Response with "format": "markdown": the bundle as text/markdown, ready to put in a prompt.

Response as JSON:

{
  "packs": [{ "id": "…", "name": "Billing reply", "score": 0.91 }],
  "pack": { "id": "…", "name": "Billing reply", "score": 0.91 },
  "matchedTags": ["Team plan"],
  "pages": [
    {
      "id": "4c1e…",
      "title": "Style rules",
      "pageType": "style",
      "role": "rule",
      "usageNote": null,
      "reason": "rule",
      "whole": true,
      "sections": [{ "headingPath": "Style rules", "content": "## Words we never use…" }],
      "file": null,
      "private": false
    },
    {
      "id": "9a72…",
      "title": "Refund policy",
      "pageType": "faq",
      "role": "reference",
      "usageNote": "Quote amounts and timelines from this page only.",
      "reason": "search",
      "whole": false,
      "sections": [
        {
          "headingPath": "Refund policy › Refunds after a downgrade",
          "content": "When a customer moves to a cheaper plan…",
          "score": 0.71
        }
      ],
      "file": null,
      "private": false
    }
  ],
  "tokenCount": 2140,
  "topScore": 0.0412,
  "markdown": "# Rules (always follow)\n\n…"
}
  • reason says why a page is there: rule, pinned, recipe (a pack's page; packs used to be called recipes), use (matched by what it's for), link (reached through a link from a page that matched), folder (a sibling in a coherent folder) or search.
  • packs lists every pack that applied, best first; pack is the first of them, or null.
  • nearest lists the closest pages when nothing matched, so a caller can see what the library has nearby.
  • plan is how the task was taken apart before searching (its names, deliverable and sub-queries), or null when that's off.
  • whole is true when the full page was sent and false for excerpts.
  • score on a searched section is its similarity to the task, from 0 to 1.
  • markdown is the same text you'd get with "format": "markdown".
  • Unlike the MCP tool, the REST API doesn't put your agent instructions at the top. If your automation should follow them, include them in its own prompt.
  • The response also includes recipe, the old name for pack, with the same value. pack is also accepted as recipe in the request.

Searches published sections.

Parameter
q required What to look for.
tag repeatable Only sections with this tag. ?tag=CFO&tag=Email
limit Up to 30. Default 10.
folder Only search this folder and its subfolders, like Market/Battlecards. Returns 404 if there's no such folder.
curl "https://getherald.ai/api/v1/search?q=refund+window&limit=5" \
  -H "Authorization: Bearer $CONTEXT_KEY"
{
  "results": [
    {
      "pageId": "9a72…",
      "headingPath": "Refund policy › How long customers have",
      "content": "Customers can ask for a refund within 30 days…",
      "tags": [],
      "score": 0.0389
    }
  ]
}

GET /tree

The folder tree with its published pages, as JSON. The same information as the get_structure MCP tool.

Parameter
folder Only this folder, like Market/Battlecards. Returns 404 if there's no such folder.
headings true to include each page's section outline.

The response is { "tree": … }, where each folder has id, title, path, pageCount (published pages here and below), pages and folders. Each page has id, title, path, type, role, pinned, tags, usageNote, file, tokens, sections, private, and outline when you asked for headings.

GET /pages

Every published page the key can see, as a flat list.

{
  "pages": [
    {
      "id": "9a72…",
      "path": "Support / Refund policy",
      "type": "faq",
      "role": "reference",
      "tags": [],
      "pinned": false,
      "usageNote": "Quote amounts and timelines from this page only.",
      "file": null,
      "private": false
    }
  ]
}

GET /pages/{page}

One published page in full. {page} is the page's id, or its exact title (URL-encoded).

curl "https://getherald.ai/api/v1/pages/Refund%20policy" \
  -H "Authorization: Bearer $CONTEXT_KEY"
{
  "page": {
    "id": "9a72…",
    "title": "Refund policy",
    "page_type": "faq",
    "role": "reference",
    "tags": [],
    "usage_note": "Quote amounts and timelines from this page only.",
    "published_markdown": "## How long customers have\n\n…",
    "published_at": "2026-10-01T09:12:44Z",
    "private": false,
    "file": null
  }
}

If the page has a file attached, file has its name, type and size, a download_url, and expires_in (seconds until the link stops working, currently 120). Download it straight away.

If the page is a table, table has its columns (name and type), title_column, row_count and the first 200 rows, each with its id and cells keyed by column name. file is then the whole table as a CSV.

GET /pages/{page}/rows

The rows of a table page, filtered, sorted and paged. The REST side of the query_table MCP tool.

Parameter
where Column:op:value, repeatable; filters combine with AND. op is one of eq, neq, contains, starts_with, gt, gte, lt, lte, in (comma-separated values), empty, not_empty.
sort A column name; prefix with - for descending.
columns Comma-separated column names to return.
limit Rows to return, default 50, up to 500.
offset For paging.

Column names match ignoring case.

curl "https://getherald.ai/api/v1/pages/Accounts/rows?where=Region:eq:EMEA&where=ARR:gt:50000&sort=-ARR&limit=20" \
  -H "Authorization: Bearer $CONTEXT_KEY"
{
  "page": { "id": "…", "title": "Accounts", "usage_note": null },
  "columns": [{ "name": "Account", "type": "text" }, { "name": "Region", "type": "text" }, { "name": "ARR", "type": "number" }],
  "rows": [{ "id": "…", "Account": "Northwind", "Region": "EMEA", "ARR": "120000" }],
  "total": 14,
  "offset": 0,
  "limit": 20,
  "unknown_columns": []
}

total is how many rows matched the filters; unknown_columns lists any column names that matched nothing (that part of the query was ignored).

Writing a table's rows

Four calls on /pages/{page}/rows change a table's rows. They need a key with edit access, and like every agent edit they change the draft: agents keep getting the published rows until the page is published. Add "publish": true to any of them to publish in the same call, which needs a key with Can edit and publish; a key without it is refused before anything is written. Each call is one entry under Activity → Agent edits, and can be undone there.

Rows are sent as objects keyed by column name (ignoring case), like {"URL": "https://…", "Views": 120}. A column the table doesn't have is refused, with the list of columns; add new columns in the app. Only the cells a row is sent with are changed.

Every call returns the same counts:

{
  "page": { "id": "…", "title": "Pages" },
  "added": 3,
  "updated": 2,
  "deleted": 1,
  "unchanged": 140,
  "changed": true,
  "added_ids": ["…", "…", "…"],
  "not_found": [],
  "published": { "changed": true, "first_publish": false, "rows": 144 }
}

changed is false when the call changed nothing (nothing is recorded then). added_ids are the ids of new rows. not_found lists rows a delete named that weren't there. published is null unless you asked to publish; its changed is false when there was nothing to publish.

PUT /pages/{page}/rows?key=Column

Syncs rows matched on a key column, so you don't need row ids: the one call an automation needs to keep a table in step with a sheet, a CRM or a feed.

Field Type
rows object[], required Every row to sync. Each must have a value in the key column, and no two the same.
mode string merge (default) adds rows with new keys and updates rows with known ones. replace does the same and also removes every row whose key isn't sent, so the table ends up exactly the rows you sent.
publish boolean Publish once the rows are written.

Values are compared as their column's type, not as text: 1,200 and 1200.0 are the same number, 10/22/2026, 2026-10-22 and 2026-10-22T00:00:00Z are the same date, and a link matches with or without a trailing slash. That applies to the key column too. A row whose cells all match is left as it is, so sending the same data twice changes nothing and reports "changed": false.

replace with an empty rows list is refused, since that would clear the table; it's usually a step upstream that failed. To remove everything, use DELETE.

curl -X PUT "https://getherald.ai/api/v1/pages/Pages/rows?key=URL" \
  -H "Authorization: Bearer $CONTEXT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rows": [{"URL": "https://example.com/pricing", "Title": "Pricing", "Updated": "2026-10-22"}], "mode": "replace", "publish": true}'

With publish: true, the page is published even when this call changed nothing, so an older draft (say, from a run that wrote rows but failed before publishing) goes live too.

POST /pages/{page}/rows

Adds rows: {"rows": [{…}, {…}]}. Nothing is matched; every row is new.

PATCH /pages/{page}/rows

Changes cells by row id: {"rows": [{"id": "…", "set": {"Status": "Live"}}]}. Row ids come from GET /pages/{page}/rows. An id that isn't in the draft is refused and nothing changes.

DELETE /pages/{page}/rows

Removes rows by id, {"ids": ["…"]}, or by key with ?key=Column and {"keys": ["https://…"]}. Rows that aren't there are listed in not_found, not refused, so repeating a delete is safe.

Limits: up to 5,000 rows per call and per table, 50 columns, and 4,000 characters a cell. A request body can be at most about 4 MB.

POST /pages/{page}/publish

Publishes a page's draft, so every agent gets it from then on: the same as Publish in the app, and the REST side of the publish_page MCP tool. It's a separate call from editing, and needs a key created with Can edit and publish (a personal key publishes only what its creator could). No body.

curl -X POST https://getherald.ai/api/v1/pages/Pricing%20FAQ/publish \
  -H "Authorization: Bearer $CONTEXT_KEY"
{
  "published": {
    "id": "…",
    "title": "Pricing FAQ",
    "changed": true,
    "first_publish": false,
    "sections": 6,
    "rows": null
  }
}

changed is false when the page was already published as it stands, in which case nothing was done. first_publish says whether the page had never been published before. sections is how many sections are now live, or rows for a table page. The publish shows up under Activity → Agent edits.

POST /move

Moves a page, or a folder and everything in it, into another folder. Needs a key with edit access. The key can only move what it can edit, into folders it can edit. Applies straight away, and can be undone in the app.

Field Type
page string Page id or exact title. Pass this or folder, not both.
folder string Folder id or path like Market/Battlecards.
to string, required Destination folder path or id. Must already exist. An empty string is the top level.
curl -X POST https://getherald.ai/api/v1/move \
  -H "Authorization: Bearer $CONTEXT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"page": "Pricing FAQ", "to": "Market/Battlecards"}'
{
  "moved": {
    "id": "…",
    "kind": "page",
    "title": "Pricing FAQ",
    "from": "Market",
    "to": "Market / Battlecards",
    "changed": true,
    "reindexing": 1
  }
}

from and to are folder paths, with an empty string for the top level. changed is false if the page was already there. reindexing is how many published pages are being re-indexed under their new folder path. That happens in the background and takes a few seconds.

DELETE /pages/{page} and /folders/{folder}

Deletes a page or table, or a folder with everything in it. {folder} is the folder's id or its path, like /folders/Market/Battlecards. It goes to Recently deleted, where it can be restored in the app for 90 days. Other people's private notes in a deleted folder stay, moved up a level.

A delete applies straight away; it isn't a draft. It needs a key with edit access to what's deleted, and deleting anything published (a published page, or a folder with one inside) needs Can edit and publish, since agents stop getting it at once. No body.

curl -X DELETE "https://getherald.ai/api/v1/folders/Market/Old%20battlecards" \
  -H "Authorization: Bearer $CONTEXT_KEY"
{
  "deleted": {
    "id": "…",
    "kind": "folder",
    "title": "Old battlecards",
    "pages": 4,
    "published": 3,
    "restorable_days": 90
  }
}

pages is how many pages went, counting a deleted page itself, and published how many of them agents were getting. Recently deleted shows the key that deleted it.

Errors

Errors come back as JSON with an error code and, usually, a message written for a person.

Status error When
400 invalid_request or bad_request The body or query is missing something or malformed, or a row write was refused (an unknown column, a duplicate key). Validation details are in issues or message. Nothing was changed.
401 unauthorized No key, a revoked key, or a key that no longer works (its creator lost access, or all of its folders were deleted).
403 forbidden The key can't do this, like a read-only key calling /move or writing rows, a key without publish access deleting something published, or a key without publish access calling /pages/{page}/publish or passing "publish": true.
404 not_found No such page, folder or destination, or not one this key can see (a page or folder that was deleted is gone from the API).
429 rate_limited The workspace has made more than 120 retrieval requests in the last minute. Wait for the number of seconds in the Retry-After header (60).

A small example in code

Fetching context before calling a model, in TypeScript:

async function contextFor(task: string): Promise<string> {
  const res = await fetch("https://getherald.ai/api/v1/context", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CONTEXT_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ task, format: "markdown", max_tokens: 8000 }),
  });
  if (res.status === 429) throw new Error("Rate limited; try again in a minute");
  if (!res.ok) throw new Error(`Context request failed: ${res.status}`);
  return res.text();
}

const task = "Welcome email for a clinic that just signed up for the Team plan";
const context = await contextFor(task);
// Put `context` in the system prompt, then ask the model to do `task`.