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…"
}reasonsays 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) orsearch.packslists every pack that applied, best first;packis the first of them, ornull.nearestlists the closest pages when nothing matched, so a caller can see what the library has nearby.planis how the task was taken apart before searching (its names, deliverable and sub-queries), ornullwhen that's off.wholeistruewhen the full page was sent andfalsefor excerpts.scoreon a searched section is its similarity to the task, from 0 to 1.markdownis 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 forpack, with the same value.packis also accepted asrecipein the request.
GET /search
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`.