Read, create, and edit Lark wiki pages and documents using the Lark Open API.
Reading Wiki Documents
When reading a wiki document, prefer the block-based method (blocks) over the plain text method (read) unless the user explicitly asks for raw text. The block method preserves structure (headings, lists, tables, formatting) and is more useful for understanding and processing document content.
blocks(default): Returns structured JSON with block types, hierarchy, and formatting. Use this when you need to understand the document layout, extract specific sections, or work with structured content.read: Returns flat plain text with no formatting. Use this only when the user explicitly asks for raw/plain text, or when you just need a quick text dump.
Workflow: Use read <node_token> first to resolve the obj_token (printed to stderr), then use blocks <obj_token> to get the structured content.
Note: The read command also works with obj_token directly (not just node_token). If you already have the obj_token, you can skip the read step and go straight to blocks <obj_token>.
Prerequisites
- Config file:
~/.lark-suite/config.jsonwithapp_idandapp_secret
- The skill still falls back to ~/.lark-wiki/config.json if the old path exists and the new one does not - If the file doesn't exist, run the init command to create it interactively (see below)
- Required Lark app scopes:
wiki:wiki,docx:document(for write operations) - The bot must have edit permission on the target wiki space
- For browser-based commands (
comment): setLARK_BASEenv var to your organization's domain (e.g.https://yourcompany.larksuite.com)
CLI Helper
All operations are available via the helper script:
python3 .claude/skills/lark-suite/scripts/lark_suite.py <command> [args]Important: All commands require network access, so use dangerouslyDisableSandbox: true for Bash calls.
Commands
Initialize credentials (first-time setup)
python3 .claude/skills/lark-suite/scripts/lark_suite.py initInteractive prompt to create or update ~/.lark-suite/config.json. Run this when credentials are missing or need updating. Shows existing values (masked for secrets) and lets you keep or replace them.
Read a wiki page
python3 .claude/skills/lark-suite/scripts/lark_suite.py read <node_token>Extract node_token from wiki URLs: https://...larksuite.com/wiki/<node_token>
Returns the plain text content of the page.
List child nodes
python3 .claude/skills/lark-suite/scripts/lark_suite.py list <node_token>Returns JSON array of child nodes with node_token, title, obj_type, has_child.
Tree view (recursive)
python3 .claude/skills/lark-suite/scripts/lark_suite.py tree <node_token> [--depth N]Prints an indented tree of all descendant nodes. Default depth: 3.
Create a wiki page
python3 .claude/skills/lark-suite/scripts/lark_suite.py create <parent_node_token> "<title>"Creates a new empty docx page under the given parent. Returns the new node's tokens.
Read document blocks (structured)
python3 .claude/skills/lark-suite/scripts/lark_suite.py blocks <document_id>Returns the full block tree as JSON. Use obj_token (not node_token) as the document ID.
Write blocks to a document
python3 .claude/skills/lark-suite/scripts/lark_suite.py write <document_id> '<blocks_json>' [--index N]Write blocks to a document. Accepts a JSON string or file path. Index -1 = append (default).
Add a comment
python3 .claude/skills/lark-suite/scripts/lark_suite.py comment <document_id> "<text>"Adds a global (whole-document) comment. The Lark Open API only supports global comments for docx files — inline comments anchored to specific text selections require browser automation (see below).
Browser-Based Commands (Playwright)
For operations the Lark API doesn't support (like inline comments), a separate Playwright-based script is available. Requires a one-time manual login.
Prerequisites:
pip3 install playwright && python3 -m playwright install chromium- Run
loginonce to authenticate: browser opens, you log in manually, session is saved to~/.playwright-lark-session
Login (one-time setup)
python3 .claude/skills/lark-suite/scripts/lark_suite_browser.py loginOpens a headed browser to the Lark login page. Log in manually, then Ctrl+C. Session persists across runs.
Add inline comment
python3 .claude/skills/lark-suite/scripts/lark_suite_browser.py inline-comment <node_token_or_url> --search '<text_to_select>' --comment '<comment_text>'Selects the specified text in the document and adds an inline comment anchored to it. Runs in headed mode by default (Lark's toolbar doesn't render in headless). Add --headless to attempt headless mode (less reliable).
Extract highlighted text
python3 .claude/skills/lark-suite/scripts/lark_suite_browser.py highlights <node_token_or_obj_token>Uses the API (no browser needed) to find all text with background_color set. Returns JSON with text, color code, and color name.
Screenshot a document
python3 .claude/skills/lark-suite/scripts/lark_suite_browser.py screenshot <node_token_or_url> [-o output.png] [--full-page]Takes a screenshot of a Lark document (headless). Useful for visual inspection.
Contact Commands
Look up user IDs by email
python3 .claude/skills/lark-suite/scripts/lark_suite.py contact-lookup user@example.com [user2@example.com ...]Returns open_id for each email. Useful for permission management (perm-add needs open_id).
Permission Commands
Add a collaborator
python3 .claude/skills/lark-suite/scripts/lark_suite.py perm-add <token> <member_open_id> [--file-type docx] [--perm edit]token: Document/file obj_tokenmember_open_id: User's open_id (fromcontact-lookup)--file-type:docx,sheet,bitable,doc,slide(default:docx)--perm:view,edit,full_access(default:edit)
List collaborators
python3 .claude/skills/lark-suite/scripts/lark_suite.py perm-list <token> [--file-type docx]Document Search
Search documents globally
python3 .claude/skills/lark-suite/scripts/lark_suite.py doc-search "<query>" [--count 20] [--doc-types docx,sheet,bitable]--doc-types: Comma-separated filter —doc,docx,sheet,bitable,slide,wiki- Returns: title, token, type, URL, owner
Block Type Reference
Creatable via API
| Type | Key | Example |
|---|---|---|
| 2 | text | {"block_type": 2, "text": {"elements": [{"text_run": {"content": "Hello", "text_element_style": {}}}], "style": {}}} |
| 3-11 | heading1-heading9 | Same structure as text, with headingN key |
| 12 | bullet | Unordered list item. Requires "style": {"align": 1, "folded": false} |
| 13 | ordered | Ordered list item. Same style requirement as bullet |
| 14 | code | Code block. "style": {"language": 12} (12=JS, 1=Python, etc.) |
| 15 | quote | Quote block |
| 17 | todo | Checkbox. "style": {"done": false, "align": 1, "folded": false} |
| 19 | callout | {"block_type": 19, "callout": {"emoji_id": "thumbsup", "background_color": 2}} |
| 22 | divider | {"block_type": 22, "divider": {}} |
| 24 | grid | Column layout. {"block_type": 24, "grid": {"column_size": 2}} |
| 26 | iframe | Embed. {"block_type": 26, "iframe": {"component": {"iframe_type": 1, "url": "..."}}} |
| 31 | table | {"block_type": 31, "table": {"property": {"column_size": 2, "row_size": 2}}} — cells are always empty on creation; populate each cell separately (see "Tables in Documents") |
| 34 | quote_container | Quote wrapper container |
NOT Creatable via API
| Type | Block | Notes |
|---|---|---|
| 16 | Equation | Explicitly excluded from create API |
| 21 | Diagram / Flowchart / Mind Map / UML | "block not support to create" |
| 27 | Image | Requires separate upload flow, then reference token |
| 23 | File | Requires separate upload flow |
| 41 | Synced Block | "block not support to create" |
| 999 | Sub-doc | "block not support to create" |
Board blocks (type 43)
Board blocks (whiteboard/画板) CAN be created via the docx API:
{"block_type": 43, "board": {}}The response includes a board.token for use with the Board API (board/v1). This is distinct from type 21 (diagram blocks) which cannot be created.
Rich Text Elements
Text elements within blocks support these styles in text_element_style:
bold: booleanitalic: booleanunderline: booleanstrikethrough: booleaninline_code: booleanbackground_color: int (only present when set) — text highlight color. Values: 1=light grey, 2=light purple, 3=yellow, 4=light green, 5=pink, etc.comment_ids: string[] (only present when set) — IDs of inline comments anchored to this text. Read-only.
Example with mixed formatting:
{
"elements": [
{"text_run": {"content": "Normal ", "text_element_style": {}}},
{"text_run": {"content": "bold", "text_element_style": {"bold": true}}},
{"text_run": {"content": " and ", "text_element_style": {}}},
{"text_run": {"content": "italic", "text_element_style": {"italic": true}}}
]
}URL → Token Extraction
Wiki URL format: https://{domain}.larksuite.com/wiki/{node_token}
The node_token is the path segment after /wiki/. To get the document_id (obj_token) needed for block operations, use the read or list command which resolves it automatically, or call:
# Manual resolution
python3 -c "
import sys; sys.path.insert(0, '.claude/skills/lark-suite/scripts')
from lark_auth import LarkAuth
import json, urllib.request
t = LarkAuth('~/.lark-suite/config.json').get_token()
r = urllib.request.urlopen(urllib.request.Request(
'https://open.larksuite.com/open-apis/wiki/v2/spaces/get_node?token=NODE_TOKEN',
headers={'Authorization': f'Bearer {t}'}))
print(json.loads(r.read())['data']['node']['obj_token'])
"Bitable (Base) Commands
List tables
python3 .claude/skills/lark-suite/scripts/lark_suite.py base-tables <app_token>List fields in a table
python3 .claude/skills/lark-suite/scripts/lark_suite.py base-fields <app_token> <table_id>List/search records
python3 .claude/skills/lark-suite/scripts/lark_suite.py base-records <app_token> <table_id> [--filter '<json>']Filter format (Lark filter syntax):
{"conjunction": "and", "conditions": [{"field_name": "Status", "operator": "is", "value": ["Done"]}]}Add a record
python3 .claude/skills/lark-suite/scripts/lark_suite.py base-add <app_token> <table_id> '{"Feature": "New item", "Status": "Backlog"}'Update a record
python3 .claude/skills/lark-suite/scripts/lark_suite.py base-update <app_token> <table_id> <record_id> '{"Status": "Done"}'Create a table
python3 .claude/skills/lark-suite/scripts/lark_suite.py base-create-table <app_token> '<table_json>'Table JSON example:
{"name": "Tasks", "default_view_name": "All", "fields": [
{"field_name": "Title", "type": 1},
{"field_name": "Status", "type": 3, "property": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
{"field_name": "Priority", "type": 2}
]}Bitable Field Types
| Type | Name | Notes |
|---|---|---|
| 1 | Text | Multi-line text |
| 2 | Number | |
| 3 | SingleSelect | With property.options |
| 4 | MultiSelect | With property.options |
| 5 | DateTime | With property.date_formatter |
| 7 | Checkbox | true/false |
| 11 | User/Person | Uses open_id |
| 13 | Phone | |
| 15 | URL/Hyperlink | |
| 17 | Attachment | Needs file upload |
| 1001 | CreatedTime | Auto-filled |
| 1002 | ModifiedTime | Auto-filled |
Bitable Patterns & Gotchas
Accessing embedded bitables (bitable tabs within spreadsheets)
Spreadsheets can contain bitable-type tabs. These are NOT accessible via the regular Sheet Values API or directly via the Bitable API using the spreadsheet token. To access them:
- Call the v2 metainfo endpoint:
GET /sheets/v2/spreadsheets/{spreadsheetToken}/metainfo - Find the bitable tab — it has
blockInfo.blockTokenin format{app_token}_{table_id} - Use the extracted
app_tokenandtable_idwith standard Bitable API endpoints
# Example: extract app_token and table_id from blockToken
block_token = sheet["blockInfo"]["blockToken"] # e.g. "Qnxmbq2euaQuI1sDOXVl5MGJg1d_tblcRkOGPnFoIr1Z"
app_token, table_id = block_token.rsplit("_", 1)Text field conversion for batch_create
When reading records via records/search, text fields (type 1) return as rich text arrays:
[{"text": "Hello", "type": "text"}]When writing via batch_create, text fields must be plain strings — passing the rich text array causes TextFieldConvFail. Convert first:
value = "".join(e.get("text", "") for e in field_value if isinstance(e, dict))User field passthrough
User fields (type 11) can be written with the exact format returned by search:
[{"id": "ou_xxx", "name": "Wind2star", "email": "user@example.com"}]Minimal format also works: [{"id": "ou_xxx"}]. Plain strings do NOT work (UserFieldConvFail).
View API limitations
The Bitable View API (PATCH /bitable/v1/apps/{app_token}/tables/{table_id}/views/{view_id}) only supports:
filter_info— filter conditionshidden_fields— column visibilityhierarchy_config— parent-child nesting
NOT supported via API (UI only): Group By, Sort, Frozen columns.
SingleSelect filter values in views
View filter conditions for SingleSelect/MultiSelect fields require option IDs (not names):
# Get option IDs from the fields endpoint first
# Then use them in the filter, wrapped as a JSON string:
"value": json.dumps(["optZDvNgr8"]) # NOT ["P0"]Option colors must be explicitly set
When creating bitable fields with select options (SingleSelect/MultiSelect), you must include the color property from the source. If omitted, the API assigns sequential defaults (0, 1, 2,...) which causes visual mismatch with the original.
# Include color when creating options
options = [{"name": opt["name"], "color": opt.get("color", 0)} for opt in source_options]Use batch_create response for record ID mapping
When copying records between tables, use the batch_create response to build old→new record ID mappings directly. Do NOT re-read records by index — leftover or deleted records can shift the order and break parent-child relationships.
resp = batch_create(app_token, table_id, records_batch)
# resp["data"]["records"] contains new records in the same order as input
for old_rec, new_rec in zip(source_batch, resp["data"]["records"]):
id_map[old_rec["record_id"]] = new_rec["record_id"]New bitables have 10 pre-created sample records
When creating a bitable via the wiki API (obj_type: "bitable"), it comes with 10 empty sample records. Delete them before or after copying data to avoid count mismatches:
# Find and delete empty records
empty = [r['record_id'] for r in records if not any(
v for v in r.get('fields', {}).values() if v is not None and v != '' and v != []
)]
if empty:
api('POST', f'/bitable/v1/apps/{app}/tables/{table}/records/batch_delete', {'records': empty})Link field write format differs from read format
- Read format:
{"link_record_ids": ["recXXX"]}(returned by search/get) - Write format:
["recXXX"](plain list of record IDs)
Writing {"link_record_ids": [...]} causes LinkFieldConvFail.
Field rename (PUT) requires type in body
When renaming a field via PUT, you must include the type parameter — not just field_name:
# Wrong: {"field_name": "New Name"} → 400 "type is required"
# Correct:
api('PUT', f'/bitable/.../fields/{field_id}', {"field_name": "New Name", "type": 1})Pagination is REQUIRED for querying records
When querying Bitable records via base-records or raw API calls, always implement pagination. The API returns results in pages — if you don't handle page_token, you'll only get the first page and miss remaining records.
The problem:
# ❌ WRONG — only gets first 100 records, misses the rest
records_req = urllib.request.Request(
f'https://open.larksuite.com/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records?page_size=100',
headers={'Authorization': f'Bearer {tenant_token}'}
)
with urllib.request.urlopen(records_req) as resp:
records = json.loads(resp.read())['data']['items'] # Only page 1!Even if the response shows "total": 302, you only got the first page.
The solution:
# ✅ CORRECT — fetches all pages
all_records = []
page_token = None
while True:
url = f'https://open.larksuite.com/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records?page_size=100'
if page_token:
url += f'&page_token={page_token}'
with urllib.request.urlopen(...) as resp:
data = json.loads(resp.read())['data']
all_records.extend(data['items'])
if not data.get('has_more', False):
break
page_token = data.get('page_token')Key checklist:
- Always check
has_morein the response - Use
page_tokenfrom the response (not request parameters) to fetch the next page - Stop when
has_moreisfalseor missing - Same rule applies to: records, tables, views, fields — any list-type endpoint
Board API (画板/Whiteboard)
The Board API (board/v1) manages content on whiteboard canvases. Boards are created as document blocks (type 43), then their content is managed via the Board API.
Creating a board
No standalone "create board" endpoint exists. Instead, create a board block inside a document:
# Creates board block — extract board.token from the response
resp = api('POST', f'/docx/v1/documents/{doc_id}/blocks/{parent_block_id}/children',
{"children": [{"block_type": 43, "board": {}}], "index": -1})
# Response data.children contains FULL block objects, not just IDs
board_token = resp["data"]["children"][0]["board"]["token"]Node creation format
api('POST', f'/board/v1/whiteboards/{board_token}/nodes', {"nodes": [
{
"type": "composite_shape", # Node type
"x": 100.0, "y": 100.0, # Position (floats)
"width": 200.0, "height": 80.0, # Dimensions
"composite_shape": {"type": "round_rect"}, # Shape subtype
"text": { # Text content (top-level, NOT inside shape)
"text": "Hello",
"font_size": 14,
"horizontal_align": "center",
"vertical_align": "mid"
},
"style": { # Visual styling
"fill_color": "#4A90D9",
"fill_opacity": 100, # 0-100 integer
"border_style": "solid", # "solid" | "none"
"border_width": "medium", # "medium" | "bold"
"border_color": "#2D6CB4"
}
}
]})Connector format
Connectors use start/end with attached_object and relative position (0.0-1.0 within the target shape):
{
"type": "connector",
"connector": {
"start": {"attached_object": {"id": "o2:1", "position": {"x": 0.5, "y": 1.0}}},
"end": {"attached_object": {"id": "o2:2", "position": {"x": 0.5, "y": 0.0}}}
}
}Free-floating connectors (no attachment) use position only:
{"start": {"position": {"x": 100.0, "y": 200.0}}, "end": {"position": {"x": 300.0, "y": 200.0}}}Do NOT use start_object/end_object at the connector level (error: "connector info empty"). arrow_style and shape fields cause field validation errors.
Composite shape subtypes (confirmed working)
rect, round_rect, diamond, ellipse
PlantUML / Mermaid diagrams
Generate diagrams from code using the create_plantuml endpoint:
api('POST', f'/board/v1/whiteboards/{board_token}/nodes/plantuml', {
"plant_uml_code": code_string,
"syntax_type": 2, # 1=PlantUML, 2=Mermaid
"diagram_type": 1 # 1=flowchart, 4=sequence (and others)
})- Returns
ids: [](empty) on success — this is normal behavior - Handles all layout, shapes, connectors, and styling automatically
- Mermaid is recommended — works reliably on new boards
- PlantUML rendering bug: New boards with ONLY PlantUML content show "Nothing on the board yet" in the doc preview, despite data being present (list/download APIs work). Workaround: add a dummy
composite_shapenode first to initialize the board renderer, or use Mermaid instead - Cannot delete document blocks via API — no delete endpoint for docx blocks
Known limitations
- Append-only: Can create and list nodes, but no update or delete endpoints
- Doc block deletion: Use
DELETE.../blocks/{parent_id}/children/batch_deletewith{"start_index": N, "end_index": M}(see below) sticky_notetype: Returns error 4003101 despite being in the SDK types- Connector styling:
arrow_styleandshapefields cause validation errors - Images: Require pre-upload via Drive API (image token reference)
- List nodes:
GET.../nodesmay return empty for boards with content (inconsistent)
Deleting boards from documents
While board nodes cannot be deleted, entire board blocks can be removed from a document using the docx batch delete API:
# Delete blocks at indices start_index..end_index-1 (0-based) under the parent block
api('DELETE', f'/docx/v1/documents/{doc_id}/blocks/{parent_block_id}/children/batch_delete',
{"start_index": 4, "end_index": 6}) # Deletes children at indices 4 and 5First list blocks to find indices, then delete. Careful: indices shift after each delete.
Other endpoints
GET /board/v1/whiteboards/{token}/download_as_image— export as JPEGGET/POST /board/v1/whiteboards/{token}/theme— get/set board themePOST /board/v1/whiteboards/{token}/nodes/plantuml— create from PlantUML/Mermaid codeGET /board/v1/whiteboards/{token}/nodes— list all nodes
Tables in Documents
Create tables using block type 31. CRITICAL: The cells parameter in the creation request is ignored — the API always creates empty cells. You must populate each cell individually as a separate step.
# 1. Create the table block — API auto-creates EMPTY cells
# Do NOT pass "cells" with content strings — they are ignored
resp = api('POST', f'/docx/v1/documents/{doc_id}/blocks/{parent_block_id}/children', {
"children": [{"block_type": 31, "table": {"property": {"row_size": 4, "column_size": 3}}}],
"index": -1
})
# Response contains cell block IDs in table.cells[] (flat list, row-major order)
cells = resp['data']['children'][0]['table']['cells']
# cells = [row0col0_id, row0col1_id, row0col2_id, row1col0_id, ...]
# 2. Populate EACH cell by inserting a text block as its child
for cell_id, content in zip(cells, ["H1", "H2", "H3", "R1C1", "R1C2", "R1C3", ...]):
api('POST', f'/docx/v1/documents/{doc_id}/blocks/{cell_id}/children', {
"children": [{"block_type": 2, "text": {
"elements": [{"text_run": {"content": content, "text_element_style": {}}}],
"style": {"align": 1, "folded": False}
}}],
"index": 0
})Gotchas:
- Each cell requires a separate API call — for large tables, add
time.sleep(0.5)every ~10 calls to avoid 429 rate limits - Cell IDs are in flat row-major order:
[r0c0, r0c1,..., r1c0, r1c1,...] - To make header cells bold, set
"bold": Trueintext_element_stylefor the firstcolumn_sizecells - Reading existing tables: use
blockscommand to get the document block tree, find type-31 blocks, extracttable.cells[]for cell IDs
Column widths
Set column_width during table creation (in property). It cannot be updated after creation via PATCH — update_table_property.column_width returns "field is invalid".
# Set widths during creation (list of pixel values, one per column)
api('POST', f'/docx/v1/documents/{doc_id}/blocks/{parent_id}/children', {
"children": [{"block_type": 31, "table": {"property": {
"row_size": 3, "column_size": 2,
"column_width": [200, 500] # pixels, must match column_size
}}}],
"index": -1
})If you need to change widths on an existing table, you must delete it and recreate it at the same index.
Tables also support merge_info (for merged cells).
Comments
Read comments
GET /drive/v1/files/{document_id}/comments?file_type=docx&page_size=50Returns all comments (both whole-document and inline). Each has comment_id, is_whole, quote, is_solved, reply_list.
Create whole-document comment
POST /drive/v1/files/{document_id}/comments?file_type=docx
Body: {"reply_list": {"replies": [{"content": {"elements": [{"type": "text_run", "text_run": {"text": "..."}}]}}]}}Reply to a comment
POST /drive/v1/files/{document_id}/comments/{comment_id}/replies?file_type=docx
Body: {"content": {"elements": [{"type": "text_run", "text_run": {"text": "..."}}]}}Resolve/unresolve a comment
PATCH /drive/v1/files/{document_id}/comments/{comment_id}?file_type=docx
Body: {"is_solved": true}Inline comment limitations
- Creating inline (anchored) comments is NOT possible via API — the endpoint only creates whole-document comments
is_wholeandquoteare response-only fields, not settable inputscomment_idsintext_element_styleis read-only (cannot be set via block PATCH)- Reading inline comments (created via UI) works fine — they have
is_whole: falseandquote
Slides API
The Slides API is very limited:
# Create empty presentation
api('POST', '/slides/v1/presentations', {"title": "My Slides"})
# Read presentation metadata (slide IDs, layouts, masters, page size)
api('GET', f'/slides/v1/presentations/{token}')
# Update title only (requires client_token as query param)
api('PATCH', f'/slides/v1/presentations/{token}?client_token={uuid}', {"title": "New Title"})Limitations:
- Cannot read slide page content (elements, shapes, text) — only metadata
- Cannot create/add new slide pages — all formats return "param is invalid" (3130001)
- Cannot modify slide content — no known endpoint
- Not in any official SDK (Go, Node, Python)
- Scopes exist:
slides:presentation:read,slides:presentation:create,slides:presentation:update,slides:presentation:write_only
Required Lark App Scopes
| Scope | Operations |
|---|---|
| (default) | Read wiki nodes, read document content, read blocks |
wiki:wiki | Create wiki nodes |
docx:document | Write document blocks (including board blocks) |
bitable:app | All bitable operations (tables, fields, records) |
board:whiteboard:node:read | List board nodes, download board as image |
board:whiteboard:node:create | Create nodes on boards |
drive:drive | Delete documents (not fully working yet) |
drive:drive:permission:member | Add/list document collaborators |
contact:user.id:readonly | Look up user IDs by email |
search:docs | Search documents globally |
slides:presentation:read | Read slides metadata |
slides:presentation:create | Create empty presentations |
slides:presentation:update | Update presentation title |
The bot also needs edit permission on the wiki space (added via wiki space settings → Members).
Troubleshooting
Wiki Document Write Returns 1770032 forBidden
Symptom: App has docx:document scope and can read wiki documents, but POST /docx/v1/documents/{doc_id}/blocks/{doc_id}/children returns {"code": 1770032, "msg": "forBidden"}. Creating wiki nodes returns 131006 "tenant needs edit permission".
Root cause: Having the docx:document scope gives the app the *capability* to edit docs, but the app's tenant_access_token also needs document-level or wiki-space-level edit permission. Two conditions must be met:
- The app must be added to the document/wiki-space's "Application" section with editable access
- Only the app's Creator/Owner can effectively grant this — if a non-owner adds the app, the association is created but the
tenant_access_tokendoes not actually receive edit permission
Fix: Have the app's Creator (visible in Lark developer console → app card) add the app to the wiki space or document's "Application" section with edit access. Non-creators adding the app will not work.
Alternative: Use user_access_token instead of tenant_access_token — this inherits the user's own document permissions, bypassing the app-level authorization requirement.