{"openapi":"3.0.3","info":{"title":"AppStruct Docs API","version":"1.0.0","description":"\nProgrammatic read and write access to your **AppStruct Docs** documents.\n\n### Authentication\nEvery request carries an API key, created from your AppStruct dashboard. Send it\nas either header:\n\n```\nAuthorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxxxxx\n# or\nx-api-key: ak_live_xxxxxxxxxxxxxxxxxxxxxxxx\n```\n\nKeys are shown **once**, at creation. A key reaches the documents its own\naccount owns and nothing else — documents shared *with* that account, and\nanything opened by a share link, are not visible through the API.\n\n### Scopes\n- `*`\n- `docs:read`\n- `docs:write`\n\n### AI assistants (MCP)\nThe same key also opens a **Model Context Protocol** server at\n`POST /v1/docs/mcp`, so Claude, Claude Code, Cursor or any other MCP client can\nread and write these documents directly. It speaks Streamable HTTP and takes the\nsame `Authorization: Bearer` header as the endpoints below.\n\nThe tools an assistant is offered are derived from the key's scopes — a\n`docs:read` key never sees the write tools at all — and every tool runs\nthrough the same service and the same limits as the REST calls documented here.\nCopy-paste configuration for each client is in the dashboard:\nhttps://appstruct.ai/dashboard/docs/api/connect\n\nBrowser clients are refused: the endpoint rejects any request carrying an\n`Origin` header, which is what the MCP specification requires against DNS\nrebinding, and real MCP clients are not browsers and send none.\n\n### You read HTML, and you write text\nA document stores its body as the sanitized rich-text HTML the editor renders,\nand `html` gives it to you exactly as stored — formatting, tables, links and\nall. Every response also carries `body.text`, the same body with the markup\nremoved, one line per paragraph.\n\nWrites go the other way: every field that adds content takes **plain text**, and\nthere is no way to send markup at all. The sanitizer that protects the editor\nruns in the browser, so a server that accepted HTML here would be a way to put\nscript into a document somebody else opens — arriving pre-trusted, because the\nclient renders whatever the server hands back. Formatting an edit already\ncarries is preserved around it; formatting it *introduces* is not something this\nAPI can do.\n\nAn edit never re-serialises the markup it did not touch. Attributes, inline\nstyles and the editor's own classes come back byte-identical, so a document\nstays exactly as the person who wrote it left it.\n\n### Editing\n`:batchUpdate` applies a list of edits **in order**, in one save. Indexes are\n**1-based** positions in `body.text`, as Google's are: index 1 is before the\nfirst character, and one past the last character is where an append lands. Each\nedit sees the document as the edits before it in the same batch left it, so read\n`body.text` once, work out your indexes, and send them together.\n\nGoogle's API wraps an index in a `location` object and a range in a `range`\nobject, each carrying a `segmentId` that names a header or a footer. This API\nedits the body only, so the flat form is documented — but the nested one is\naccepted too, and so is Google's `containsText: {text, matchCase}`, because a\nscript ported from their API should not have to discover the difference from a\n`400`.\n\nA batch either applies whole or changes nothing: an edit the server cannot\nunderstand is refused with the position of the request that was wrong, and\nnothing before it is written. It is one save, so the document takes a single\nversion-history snapshot and collaborators get a single notification — the same\nas if the edit had been typed. Compare `revisionId` against the one you read to\nknow whether anybody changed the text underneath you first.\n\n### Limits\n- **120 requests per minute per key**, plus a coarser\n  **600 per minute per IP**. Both answer `429` with\n  `Retry-After`; every response carries `X-RateLimit-Limit`,\n  `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds), and the per-IP\n  tier reports the same three suffixed `-ip`.\n- **100,000 requests per rolling 24\n  hours per account**, shared across every key. The Sheets API keeps its own\nseparate allowance, so traffic there does not spend this one.\n  Reported in `X-AppStruct-RateLimit-Daily` and\n  `X-AppStruct-RateLimit-Daily-Remaining`.\n- **100 edits** per `:batchUpdate` call, each adding at most\n  **100,000 characters**.\n- A title of at most **200 characters**.\n- List endpoints take `?limit=` (1–200, default\n  50) and `?offset=`, and return `{ data, meta }`.\n\n### Errors\nStandard HTTP status codes with a JSON body. Switch on `statusCode`, not on the\nhuman-readable `message`.\n\n| Status | Meaning |\n| ------ | ------- |\n| 400 | The request, or an edit index, could not be understood |\n| 401 | Missing, invalid, expired or revoked API key |\n| 403 | The key lacks the required scope |\n| 404 | No such document on this account |\n| 429 | A rate limit or the daily budget was exceeded |\n"},"servers":[{"url":"/","description":"Current host"}],"tags":[{"name":"Documents","description":"Finding documents, reading their contents, and creating new ones"},{"name":"Editing","description":"Changing the text of a document that already exists"}],"components":{"securitySchemes":{"ApiKey":{"type":"http","scheme":"bearer","bearerFormat":"API key","description":"Your Docs API key, e.g. ak_live_… (also accepted in the x-api-key header)."},"ApiKeyHeader":{"type":"apiKey","in":"header","name":"x-api-key","description":"Your Docs API key, as an alternative to Authorization: Bearer."}},"schemas":{"Error":{"type":"object","properties":{"statusCode":{"type":"integer","example":403},"error":{"type":"string","example":"FORBIDDEN"},"message":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}}},"DocumentSummary":{"type":"object","description":"A document without its body. This is what list responses carry — a page of 50 documents with their HTML inlined would be megabytes of a response nobody asked for.","properties":{"documentId":{"type":"string"},"title":{"type":"string"},"starred":{"type":"boolean"},"trashed":{"type":"boolean"},"folderId":{"type":"string","nullable":true,"description":"The folder the document sits in, or null at the root of the drive."},"updatedAt":{"type":"string","format":"date-time"}}},"Document":{"type":"object","properties":{"documentId":{"type":"string"},"title":{"type":"string"},"html":{"type":"string","description":"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":{"type":"object","description":"The index space every edit addresses. Nested rather than flat so a script ported from Google's API keeps reading `body.text`.","properties":{"text":{"type":"string","description":"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":{"type":"string","description":"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":{"type":"boolean"},"trashed":{"type":"boolean"},"folderId":{"type":"string","nullable":true},"updatedAt":{"type":"string","format":"date-time"}}},"PageMeta":{"type":"object","properties":{"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"hasMore":{"type":"boolean"}}},"DocumentList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DocumentSummary"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}},"InsertTextRequest":{"type":"object","required":["text"],"description":"Insert a run of text into the body.","properties":{"text":{"type":"string","maxLength":100000,"description":"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."},"index":{"type":"integer","minimum":1,"description":"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."}}},"DeleteContentRangeRequest":{"type":"object","required":["startIndex","endIndex"],"description":"Remove a span of the body.","properties":{"startIndex":{"type":"integer","minimum":1,"description":"First character to delete, inclusive, 1-based. Google's `\"range\": {…}` wrapper is accepted around this pair too."},"endIndex":{"type":"integer","minimum":1,"description":"One past the last character to delete. `startIndex` equal to `endIndex` deletes nothing rather than failing."}}},"ReplaceAllTextRequest":{"type":"object","required":["containsText","replaceText"],"description":"Find and replace across the whole body.","properties":{"containsText":{"type":"string","description":"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."},"replaceText":{"type":"string","maxLength":100000,"description":"What to put in its place. An empty string deletes every match."},"matchCase":{"type":"boolean","default":false,"description":"Whether the search is case-sensitive. Defaults to false."}}},"UpdateTitleRequest":{"type":"object","required":["title"],"description":"Rename the document.","properties":{"title":{"type":"string","maxLength":200,"description":"The new title. This is the name in the documents list, not a heading in the body — nothing is written into the text."}}},"BatchUpdateRequest":{"type":"object","minProperties":1,"maxProperties":1,"description":"One edit. Exactly one field is set; a request object naming two kinds of edit is rejected rather than guessed at.","properties":{"insertText":{"$ref":"#/components/schemas/InsertTextRequest"},"deleteContentRange":{"$ref":"#/components/schemas/DeleteContentRangeRequest"},"replaceAllText":{"$ref":"#/components/schemas/ReplaceAllTextRequest"},"updateTitle":{"$ref":"#/components/schemas/UpdateTitleRequest"}}},"BatchUpdateReply":{"type":"object","description":"The reply to the request at the same position. Edits with nothing to report answer with an empty object, so the two arrays always line up.","properties":{"occurrencesChanged":{"type":"integer","description":"How many matches a replaceAllText actually replaced. Zero is a success, not an error."},"insertedLength":{"type":"integer","description":"How many characters an insertText added."},"deletedLength":{"type":"integer","description":"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`."}}},"BatchUpdateResponse":{"type":"object","properties":{"documentId":{"type":"string"},"replies":{"type":"array","items":{"$ref":"#/components/schemas/BatchUpdateReply"}},"updatedAt":{"type":"string","format":"date-time"}}}}},"paths":{"/v1/docs/documents":{"get":{"operationId":"listDocuments","tags":["Documents"],"summary":"List documents","description":"Every document this account owns, newest edit first. Bodies are left out — fetch one document to read its HTML.\n\nRequires the `docs:read` scope.","security":[{"ApiKey":[]},{"ApiKeyHeader":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"How many documents to return."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0},"description":"How many to skip, for paging."},{"name":"includeTrashed","in":"query","schema":{"type":"boolean","default":false},"description":"Include documents in the trash. They are still owned and still readable; they are simply hidden in the app."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentList"}}}},"401":{"description":"Missing, invalid, expired or revoked API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key lacks the required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found on this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit or daily budget exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createDocument","tags":["Documents"],"summary":"Create a document","description":"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`.\n\nRequires the `docs:write` scope.","security":[{"ApiKey":[]},{"ApiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"title":{"type":"string","maxLength":200},"text":{"type":"string","maxLength":100000,"description":"An initial body as plain text, one paragraph per line. Not HTML — see the note above on why this API never accepts markup."},"folderId":{"type":"string","maxLength":160,"description":"Put the new document in this folder. Omit to leave it at the root."}}},"example":{"title":"Q3 launch plan","text":"Launch plan\nStatus: DRAFT"}}}},"responses":{"200":{"description":"The document as stored, including the id you will need for every later call.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"400":{"description":"Request validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key lacks the required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found on this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit or daily budget exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/docs/documents/{documentId}":{"get":{"operationId":"getDocument","tags":["Documents"],"summary":"Retrieve a document","description":"One document with its full body, as both HTML and plain text.\n\nRequires the `docs:read` scope.","security":[{"ApiKey":[]},{"ApiKeyHeader":[]}],"parameters":[{"name":"documentId","in":"path","required":true,"schema":{"type":"string"},"description":"The document id, as returned by GET /v1/docs/documents."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"401":{"description":"Missing, invalid, expired or revoked API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key lacks the required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found on this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit or daily budget exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/docs/documents/{documentId}:batchUpdate":{"post":{"operationId":"batchUpdateDocument","tags":["Editing"],"summary":"Edit a document","description":"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.\n\nRequires the `docs:write` scope.","security":[{"ApiKey":[]},{"ApiKeyHeader":[]}],"parameters":[{"name":"documentId","in":"path","required":true,"schema":{"type":"string"},"description":"The document id, as returned by GET /v1/docs/documents."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["requests"],"properties":{"requests":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/BatchUpdateRequest"}}}},"example":{"requests":[{"replaceAllText":{"containsText":"DRAFT","replaceText":"Final"}},{"insertText":{"text":"\nApproved by the design review."}}]}}}},"responses":{"200":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchUpdateResponse"}}}},"400":{"description":"Request validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key lacks the required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found on this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit or daily budget exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"x-mcp-tools":[{"name":"list_documents","title":"List the account’s documents","description":"List the documents this account owns, most recently edited first, with the id\nevery other tool needs.\n\nStart here when the user names a document in words (\"the offer letter\", \"my\nmeeting notes\") and you need its id. If they described something WRITTEN INSIDE\na document — a clause, a name, a figure — use search_documents instead, which\nreads the text.\n\nReturns id, title and last-edited time, and nothing of the body: use\nget_document for that. Only documents this account OWNS — anything shared with\nit, or opened from a share link, is not reachable with an API key.","scopes":["docs:read"]},{"name":"get_document","title":"Read a document","description":"Read one document: its title, when it was last edited, how long it is, and its\ntext.\n\nCall this before you change anything. insert_text addresses a position by index\ninto exactly the text this returns, so an edit placed without reading first is a\nguess. Index 1 is the first character and the character count this reports, plus\none, is the end of the document.\n\nLong documents come back 20,000 characters at a time. When there is more, the\nreply says so and gives you the `start` for the next call — the text is never\nsilently cut short.\n\nThe words only. A document’s bold, fonts, images and page setup are not returned\nhere, and nothing in this server changes them.","scopes":["docs:read"]},{"name":"search_documents","title":"Find documents by what they say","description":"Find the account’s documents whose TITLE or TEXT contains a piece of text, and\nget back each one’s id, how many times it matched and the words around the first\nmatch.\n\nUse it when the user describes content rather than a file: \"which document\nmentions the Riga office\", \"where did I write down the renewal date\", \"find the\ndraft that quotes £4,200\". Matching is case-insensitive substring, so a shorter\nfragment finds more.\n\nThe index beside each match is the same index insert_text takes, so a result can\nbe acted on directly — though get_document first is still the safer habit for\nanything but the smallest edit.\n\nOpens the 40 most recently edited documents at most, and says so when it stopped\nearly. Trashed documents are never opened.","scopes":["docs:read"]},{"name":"create_document","title":"Create a document","description":"Create a new document, optionally with its first draft already in it, and get\nback the id every other tool needs.\n\nUse it whenever the user asks for something new to be written — \"draft a reply\nto this\", \"write up the meeting\", \"start a spec\". Prefer creating a document\nover adding to an existing one: text appended to somebody’s draft is far harder\nfor them to undo than a document they did not want is to delete.\n\nGive it a real title. It is what the person sees in their document list and what\nlist_documents and search_documents match on, so \"Untitled\" makes the document\nhard to find again.\n\nThe text is plain text and each line becomes a paragraph. Markup is escaped\nrather than interpreted, so HTML you send appears as visible characters —\nformatting is applied by the person afterwards, in the editor.","scopes":["docs:write"]},{"name":"insert_text","title":"Insert text into a document","description":"Insert text at one position in a document, pushing whatever follows along. It\nreplaces nothing.\n\nLeave `index` out to add the text at the very END of the document, which is what\n\"add a note to my meeting notes\" usually means and needs no read at all.\n\nTo place it anywhere else, READ FIRST. The index counts characters of exactly\nthe text get_document returns: index 1 is the first character, and an index\nchosen without reading is a guess that lands mid-word. An index past the end of\nthe document is refused rather than clamped, because a clamped index writes to a\nreal but wrong place and reports success.\n\nYou supply the separators. Inserting \"Next steps\" against the end of a paragraph\nproduces \"…as agreed.Next steps\" — begin the text with a newline whenever it\nbelongs on a line of its own.\n\nEverything after the insertion point shifts along by the length of what you\ninserted, so indexes you noted earlier in the same document are stale afterwards.","scopes":["docs:write"]},{"name":"replace_all_text","title":"Replace every occurrence of some text","description":"Replace every occurrence of a piece of text in one document, and get back how\nmany there were.\n\nThis is the tool for a correction that appears in more than one place: a renamed\ncompany, a wrong date, a placeholder left in a draft. The count is the point —\n\"0 occurrences changed\" means the document did not say what you thought, and\nreporting the change as done would be wrong.\n\nTHIS REPLACES TEXT AND THERE IS NO UNDO TOOL. Match on something long enough to\nbe unambiguous: replacing \"Ltd\" hits the \"Ltd\" inside every other company name\ntoo. Read the document first if you are not certain what the match will catch.\n\nMatching ignores case unless you ask it not to, and every occurrence is replaced\nin one save. Leave `replace` out to delete the matched text instead.","scopes":["docs:write"]},{"name":"rename_document","title":"Rename a document","description":"Change a document’s title. Not one word of its text is touched.\n\nUse it when a draft has outgrown the name it was started with, or when the user\nasks for one to be called something else. The title is what they see in their\ndocument list and what list_documents and search_documents match on, so a good\none is how the document gets found again.\n\nThe title is the document’s NAME, not a heading inside it: renaming writes\nnothing into the body. The old title is replaced rather than kept alongside, so\npass the id from list_documents rather than one you remember — a document\nrenamed since you last looked is no longer the one you think it is.","scopes":["docs:write"]}]}