Skip to content
AppStruct Docs API v1.0.0
OpenAPI Get an API key

AppStruct Docs API

Version 1.0.0 · openapi.json

Programmatic read and write access to your AppStruct Docs documents.

Authentication

Every request carries an API key, created from your AppStruct dashboard. Send it as either header:

Authorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxxxxx
# or
x-api-key: ak_live_xxxxxxxxxxxxxxxxxxxxxxxx

Keys are shown once, at creation. A key reaches the documents its own account owns and nothing else — documents shared with that account, and anything opened by a share link, are not visible through the API.

Scopes

  • *
  • docs:read
  • docs:write

AI assistants (MCP)

The same key also opens a Model Context Protocol server at POST /v1/docs/mcp, so Claude, Claude Code, Cursor or any other MCP client can read and write these documents directly. It speaks Streamable HTTP and takes the same Authorization: Bearer header as the endpoints below.

The tools an assistant is offered are derived from the key's scopes — a docs:read key never sees the write tools at all — and every tool runs through the same service and the same limits as the REST calls documented here. Copy-paste configuration for each client is in the dashboard: https://appstruct.ai/dashboard/docs/api/connect

Browser clients are refused: the endpoint rejects any request carrying an Origin header, which is what the MCP specification requires against DNS rebinding, and real MCP clients are not browsers and send none.

You read HTML, and you write text

A document stores its body as the sanitized rich-text HTML the editor renders, and html gives it to you exactly as stored — formatting, tables, links and all. Every response also carries body.text, the same body with the markup removed, one line per paragraph.

Writes go the other way: every field that adds content takes plain text, and there is no way to send markup at all. The sanitizer that protects the editor runs in the browser, so a server that accepted HTML here would be a way to put script into a document somebody else opens — arriving pre-trusted, because the client renders whatever the server hands back. Formatting an edit already carries is preserved around it; formatting it introduces is not something this API can do.

An edit never re-serialises the markup it did not touch. Attributes, inline styles and the editor's own classes come back byte-identical, so a document stays exactly as the person who wrote it left it.

Editing

:batchUpdate applies a list of edits in order, in one save. Indexes are 1-based positions in body.text, as Google's are: index 1 is before the first character, and one past the last character is where an append lands. Each edit sees the document as the edits before it in the same batch left it, so read body.text once, work out your indexes, and send them together.

Google's API wraps an index in a location object and a range in a range object, each carrying a segmentId that names a header or a footer. This API edits the body only, so the flat form is documented — but the nested one is accepted too, and so is Google's containsText: {text, matchCase}, because a script ported from their API should not have to discover the difference from a 400.

A batch either applies whole or changes nothing: an edit the server cannot understand is refused with the position of the request that was wrong, and nothing before it is written. It is one save, so the document takes a single version-history snapshot and collaborators get a single notification — the same as if the edit had been typed. Compare revisionId against the one you read to know whether anybody changed the text underneath you first.

Limits

  • 120 requests per minute per key, plus a coarser 600 per minute per IP. Both answer 429 with Retry-After; every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds), and the per-IP tier reports the same three suffixed -ip.
  • 100,000 requests per rolling 24 hours per account, shared across every key. The Sheets API keeps its own

separate allowance, so traffic there does not spend this one. Reported in X-AppStruct-RateLimit-Daily and X-AppStruct-RateLimit-Daily-Remaining.

  • 100 edits per :batchUpdate call, each adding at most 100,000 characters.
  • A title of at most 200 characters.
  • List endpoints take ?limit= (1–200, default 50) and ?offset=, and return { data, meta }.

Errors

Standard HTTP status codes with a JSON body. Switch on statusCode, not on the human-readable message.

StatusMeaning
400The request, or an edit index, could not be understood
401Missing, invalid, expired or revoked API key
403The key lacks the required scope
404No such document on this account
429A rate limit or the daily budget was exceeded

The tools an assistant is given

Derived from the key's scopes — a read-only key never sees the write tools at all.

ToolWhat it doesNeeds
list_documents List the account’s documents
List the documents this account owns, most recently edited first, with the id every other tool needs. Start here when the user names a document in words ("the offer letter", "my meeting notes") and you need its id. If they described something WRITTEN INSIDE a document — a clause, a name, a figure — use search_documents instead, which reads the text. Returns id, title and last-edited time, and nothing of the body: use get_document for that. Only documents this account OWNS — anything shared with it, or opened from a share link, is not reachable with an API key.
docs:read
get_document Read a document
Read one document: its title, when it was last edited, how long it is, and its text. Call this before you change anything. insert_text addresses a position by index into exactly the text this returns, so an edit placed without reading first is a guess. Index 1 is the first character and the character count this reports, plus one, is the end of the document. Long documents come back 20,000 characters at a time. When there is more, the reply says so and gives you the `start` for the next call — the text is never silently cut short. The words only. A document’s bold, fonts, images and page setup are not returned here, and nothing in this server changes them.
docs:read
search_documents Find documents by what they say
Find the account’s documents whose TITLE or TEXT contains a piece of text, and get back each one’s id, how many times it matched and the words around the first match. Use it when the user describes content rather than a file: "which document mentions the Riga office", "where did I write down the renewal date", "find the draft that quotes £4,200". Matching is case-insensitive substring, so a shorter fragment finds more. The index beside each match is the same index insert_text takes, so a result can be acted on directly — though get_document first is still the safer habit for anything but the smallest edit. Opens the 40 most recently edited documents at most, and says so when it stopped early. Trashed documents are never opened.
docs:read
create_document Create a document
Create a new document, optionally with its first draft already in it, and get back the id every other tool needs. Use it whenever the user asks for something new to be written — "draft a reply to this", "write up the meeting", "start a spec". Prefer creating a document over adding to an existing one: text appended to somebody’s draft is far harder for them to undo than a document they did not want is to delete. Give it a real title. It is what the person sees in their document list and what list_documents and search_documents match on, so "Untitled" makes the document hard to find again. The text is plain text and each line becomes a paragraph. Markup is escaped rather than interpreted, so HTML you send appears as visible characters — formatting is applied by the person afterwards, in the editor.
docs:write
insert_text Insert text into a document
Insert text at one position in a document, pushing whatever follows along. It replaces nothing. Leave `index` out to add the text at the very END of the document, which is what "add a note to my meeting notes" usually means and needs no read at all. To place it anywhere else, READ FIRST. The index counts characters of exactly the text get_document returns: index 1 is the first character, and an index chosen without reading is a guess that lands mid-word. An index past the end of the document is refused rather than clamped, because a clamped index writes to a real but wrong place and reports success. You supply the separators. Inserting "Next steps" against the end of a paragraph produces "…as agreed.Next steps" — begin the text with a newline whenever it belongs on a line of its own. Everything after the insertion point shifts along by the length of what you inserted, so indexes you noted earlier in the same document are stale afterwards.
docs:write
replace_all_text Replace every occurrence of some text
Replace every occurrence of a piece of text in one document, and get back how many there were. This is the tool for a correction that appears in more than one place: a renamed company, a wrong date, a placeholder left in a draft. The count is the point — "0 occurrences changed" means the document did not say what you thought, and reporting the change as done would be wrong. THIS REPLACES TEXT AND THERE IS NO UNDO TOOL. Match on something long enough to be unambiguous: replacing "Ltd" hits the "Ltd" inside every other company name too. Read the document first if you are not certain what the match will catch. Matching ignores case unless you ask it not to, and every occurrence is replaced in one save. Leave `replace` out to delete the matched text instead.
docs:write
rename_document Rename a document
Change a document’s title. Not one word of its text is touched. Use it when a draft has outgrown the name it was started with, or when the user asks for one to be called something else. The title is what they see in their document list and what list_documents and search_documents match on, so a good one is how the document gets found again. The title is the document’s NAME, not a heading inside it: renaming writes nothing into the body. The old title is replaced rather than kept alongside, so pass the id from list_documents rather than one you remember — a document renamed since you last looked is no longer the one you think it is.
docs:write

Documents

Finding documents, reading their contents, and creating new ones

GET /v1/docs/documents

List documents

Every document this account owns, newest edit first. Bodies are left out — fetch one document to read its HTML.

Requires the docs:read scope.

Example request
curl -X GET "https://api.appstruct.cloud/v1/docs/documents" \
  -H "Authorization: Bearer ak_live_your_key_here"

Query parameters

NameTypeDescription
limit integerdefaults to 50 How many documents to return.
offset integer How many to skip, for paging.
includeTrashed booleandefaults to false Include documents in the trash. They are still owned and still readable; they are simply hidden in the app.

Response200

Success

FieldTypeDescription
data object[]
data[].documentId string
data[].title string
data[].starred boolean
data[].trashed boolean
data[].folderId string The folder the document sits in, or null at the root of the drive.
data[].updatedAt string (date-time)
meta object
meta.total integer
meta.limit integer
meta.offset integer
meta.hasMore boolean

Errors

  • 401Missing, invalid, expired or revoked API key
  • 403API key lacks the required scope
  • 404Not found on this account
  • 429Rate limit or daily budget exceeded
POST /v1/docs/documents

Create a document

Make a new document. Send text to give it a body straight away, or leave it out for an empty one you fill in with :batchUpdate.

Requires the docs:write scope.

Example request
curl -X POST "https://api.appstruct.cloud/v1/docs/documents" \
  -H "Authorization: Bearer ak_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"title":"Q3 launch plan","text":"Launch plan\nStatus: DRAFT"}'

Request bodyapplication/json, required

FieldTypeDescription
titlerequired string
text string An initial body as plain text, one paragraph per line. Not HTML — see the note above on why this API never accepts markup.
folderId string Put the new document in this folder. Omit to leave it at the root.
Example body
{
  "title": "Q3 launch plan",
  "text": "Launch plan\nStatus: DRAFT"
}

Response200

The document as stored, including the id you will need for every later call.

FieldTypeDescription
documentId string
title string
html string The document body as sanitized rich-text HTML — the same markup the editor renders. Headings, lists, tables, links and inline styling all survive a round trip; anything the sanitizer does not recognise is dropped on the way in.
body object The index space every edit addresses. Nested rather than flat so a script ported from Google's API keeps reading body.text.
body.text string The body as plain text, with the markup removed — one line per paragraph, list item, heading or table cell. Character 1 is the first character of the document, so this is the string to search when working out an index.
revisionId string An opaque token that changes when, and only when, the addressable content changes. Read a document, compute your indexes, and compare this before you write: if it still matches, the text you measured is the text you are about to edit. It is not a timestamp — a save that rewrote nothing leaves it alone.
starred boolean
trashed boolean
folderId string
updatedAt string (date-time)

Errors

  • 400Request validation failed
  • 401Missing, invalid, expired or revoked API key
  • 403API key lacks the required scope
  • 404Not found on this account
  • 429Rate limit or daily budget exceeded
GET /v1/docs/documents/{documentId}

Retrieve a document

One document with its full body, as both HTML and plain text.

Requires the docs:read scope.

Example request
curl -X GET "https://api.appstruct.cloud/v1/docs/documents/<documentId>" \
  -H "Authorization: Bearer ak_live_your_key_here"

Path parameters

NameTypeDescription
documentIdrequired string The document id, as returned by GET /v1/docs/documents.

Response200

Success

FieldTypeDescription
documentId string
title string
html string The document body as sanitized rich-text HTML — the same markup the editor renders. Headings, lists, tables, links and inline styling all survive a round trip; anything the sanitizer does not recognise is dropped on the way in.
body object The index space every edit addresses. Nested rather than flat so a script ported from Google's API keeps reading body.text.
body.text string The body as plain text, with the markup removed — one line per paragraph, list item, heading or table cell. Character 1 is the first character of the document, so this is the string to search when working out an index.
revisionId string An opaque token that changes when, and only when, the addressable content changes. Read a document, compute your indexes, and compare this before you write: if it still matches, the text you measured is the text you are about to edit. It is not a timestamp — a save that rewrote nothing leaves it alone.
starred boolean
trashed boolean
folderId string
updatedAt string (date-time)

Errors

  • 401Missing, invalid, expired or revoked API key
  • 403API key lacks the required scope
  • 404Not found on this account
  • 429Rate limit or daily budget exceeded

Editing

Changing the text of a document that already exists

POST /v1/docs/documents/{documentId}:batchUpdate

Edit a document

Apply a list of edits to the body — insert text, delete a span, replace every occurrence of a phrase, or rename the document. Edits run in the order you give them.

Requires the docs:write scope.

Example request
curl -X POST "https://api.appstruct.cloud/v1/docs/documents/<documentId>:batchUpdate" \
  -H "Authorization: Bearer ak_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"requests":[{"replaceAllText":{"containsText":"DRAFT","replaceText":"Final"}},{"insertText":{"text":"\nApproved by the design review."}}]}'

Path parameters

NameTypeDescription
documentIdrequired string The document id, as returned by GET /v1/docs/documents.

Request bodyapplication/json, required

FieldTypeDescription
requestsrequired object[]
requests[].insertText object Insert a run of text into the body.
requests[].insertText.textrequired string The text to insert. Plain text, never markup — a newline becomes a line break inside the paragraph it lands in. (Note the difference from text on create, which starts a new paragraph per line.) Text inserted at the end of the document joins the last paragraph.
requests[].insertText.index integer Where to insert, as a 1-based index into body.text — 1 is before the first character, and one past the last character appends. Omit to append at the end. Google's "location": {"index": n} is accepted here too.
requests[].deleteContentRange object Remove a span of the body.
requests[].deleteContentRange.startIndexrequired integer First character to delete, inclusive, 1-based. Google's "range": {…} wrapper is accepted around this pair too.
requests[].deleteContentRange.endIndexrequired integer One past the last character to delete. startIndex equal to endIndex deletes nothing rather than failing.
requests[].replaceAllText object Find and replace across the whole body.
requests[].replaceAllText.containsTextrequired string The text to look for. Matched against body.text, so a phrase split across a bold run still matches. Google's object form, {"text": …, "matchCase": …}, is accepted as well.
requests[].replaceAllText.replaceTextrequired string What to put in its place. An empty string deletes every match.
requests[].replaceAllText.matchCase boolean Whether the search is case-sensitive. Defaults to false.
requests[].updateTitle object Rename the document.
requests[].updateTitle.titlerequired string The new title. This is the name in the documents list, not a heading in the body — nothing is written into the text.
Example body
{
  "requests": [
    {
      "replaceAllText": {
        "containsText": "DRAFT",
        "replaceText": "Final"
      }
    },
    {
      "insertText": {
        "text": "\nApproved by the design review."
      }
    }
  ]
}

Response200

Applied as ONE save, so the document takes a single version snapshot and collaborators get a single notification. replies lines up with requests, one entry each.

FieldTypeDescription
documentId string
replies object[]
replies[].occurrencesChanged integer How many matches a replaceAllText actually replaced. Zero is a success, not an error.
replies[].insertedLength integer How many characters an insertText added.
replies[].deletedLength integer How many characters a deleteContentRange actually removed. Measured rather than assumed: a paragraph boundary inside the range survives the delete, so this can be smaller than endIndex - startIndex.
updatedAt string (date-time)

Errors

  • 400Request validation failed
  • 401Missing, invalid, expired or revoked API key
  • 403API key lacks the required scope
  • 404Not found on this account
  • 429Rate limit or daily budget exceeded

Ready to build?

Keys are created in the AppStruct dashboard and shown once, at creation.

Get an API key