lark
Skill: Lark Integration
Prerequisites
- MCP server
tn-lark: Lark MCP server must be configured in your Claude Code MCP settings- Provides
mcp__tn-lark__*tools (document read, messaging, contacts, bitable, wiki) - See Lark Open Platform docs for server setup
- Provides
- Secrets file: Create
skills/lark/lark.secrets.yaml(gitignored):
Get the app secret from Lark Developer Console (app ID is inapp_secret: "<your-lark-app-secret>"lark-config.yaml) - Dependencies:
curl,jqmust be available in PATH (used bylark-auth.shfor Direct API calls) - Wiki access (if editing wiki docs): App must be added as wiki space member with edit permission
Purpose
Integrate with Lark/Feishu platform: documents, messenger, contacts, and base (Bitable).
When to Use
- Reading Lark documents (wiki, docx)
- Editing Lark documents (text, headings, tables, etc.)
- Extracting tables from documents
- Sending messages to chats
- Looking up user information
- Querying Lark Base/Bitable data
Configuration
All config files are bundled within this skill:
| File | Purpose |
|---|---|
lark-config.yaml |
API config, app_id |
lark.secrets.yaml |
app_secret (gitignored) |
scripts/lark-auth.sh |
Token management with caching |
lark-token.cache |
Cached token (gitignored, auto-generated) |
Helper Functions
The scripts/lark-auth.sh script provides:
| Function | Purpose |
|---|---|
get_lark_token |
Get/refresh tenant access token |
lark_api METHOD endpoint [data] |
Make authenticated API call |
get_document_blocks doc_id |
Get single page of blocks |
get_all_document_blocks doc_id |
Get all blocks with pagination |
update_block doc_id block_id content |
Update block text |
update_block_styled doc_id block_id elements_json |
Update with styled text |
batch_update_blocks doc_id updates_json |
Batch update multiple blocks |
create_block doc_id parent_id type content |
Create new block |
insert_table_row doc_id table_id row_index |
Add table row |
insert_table_column doc_id table_id col_index |
Add table column |
delete_table_rows doc_id table_id start end |
Remove table rows |
batch_delete_blocks doc_id parent_id start end |
Delete child blocks by index range |
get_wiki_node wiki_token |
Get wiki node info (for editing) |
retry_with_backoff retries delay_ms cmd |
Retry transient errors |
Usage
# Source the auth script (uses relative path internally)
SKILL_DIR="$(dirname "${BASH_SOURCE[0]}")"
source "$SKILL_DIR/scripts/lark-auth.sh"
# Or via repo root
REPO_ROOT="$(git rev-parse --show-toplevel)"
source "$REPO_ROOT/projects/truenorth/skills/lark/scripts/lark-auth.sh"
# Then use functions
TOKEN=$(get_lark_token)
lark_api GET "/docx/v1/documents/$DOC_ID/raw_content"
Document Editing
Overview
Lark documents can be edited via the Direct API (not MCP). All text-type blocks support editing.
Key Points:
- Use batch update for efficiency (up to 200 blocks per request)
- Retry with exponential backoff for transient 9499 errors
- Wiki documents require app to be wiki space member
Edit a Block
source "$SKILL_DIR/scripts/lark-auth.sh"
# Simple text update
update_block "$DOC_ID" "$BLOCK_ID" "New content here"
# With retry for reliability
retry_with_backoff 5 500 "update_block '$DOC_ID' '$BLOCK_ID' 'New content'"
Edit with Styling
# Bold, italic, underline, strikethrough, inline_code
update_block_styled "$DOC_ID" "$BLOCK_ID" '[
{"content": "Bold text", "bold": true},
{"content": " normal "},
{"content": "italic", "italic": true}
]'
Batch Update
batch_update_blocks "$DOC_ID" '[
{"block_id": "block1", "content": "First update"},
{"block_id": "block2", "content": "Second update"}
]'
Create Blocks
# Block types: 2=text, 3=h1, 4=h2, ..., 12=bullet, 13=ordered, 14=code, 15=quote, 17=todo
create_block "$DOC_ID" "$PAGE_ID" 2 "New paragraph"
create_block "$DOC_ID" "$PAGE_ID" 3 "New Heading 1"
create_block "$DOC_ID" "$PAGE_ID" 12 "Bullet item"
Table Operations
# Get table block_id from document blocks
insert_table_row "$DOC_ID" "$TABLE_ID" 2 # Insert at row 2
insert_table_column "$DOC_ID" "$TABLE_ID" 1 # Insert at column 1
delete_table_rows "$DOC_ID" "$TABLE_ID" 2 3 # Delete rows 2-3
# Edit table cells: cells are containers with child text blocks
BLOCKS=$(get_document_blocks "$DOC_ID")
CELL_CHILD=$(echo "$BLOCKS" | jq -r '.data.items[] | select(.block_id == "CELL_ID") | .children[0]')
update_block "$DOC_ID" "$CELL_CHILD" "Cell content"
Edit Wiki Documents
Wiki documents use the same API but need the underlying obj_token:
# Get wiki node info
NODE_INFO=$(get_wiki_node "WIKI_NODE_TOKEN")
OBJ_TOKEN=$(echo "$NODE_INFO" | jq -r '.node.obj_token')
# Edit using obj_token
update_block "$OBJ_TOKEN" "$BLOCK_ID" "Updated wiki content"
Note: App must be added as wiki space member with edit permission.
Editable Block Types
| Type | ID | Content Key |
|---|---|---|
| Text | 2 | text |
| Heading 1-9 | 3-11 | heading1-heading9 |
| Bullet | 12 | bullet |
| Ordered | 13 | ordered |
| Code | 14 | code |
| Quote | 15 | quote |
| Todo | 17 | todo |
Container blocks (Callout=19, Table Cell=32, Grid Column=25) hold child text blocks - edit the children.
Commands
/lark read <doc-id>
Read and parse a Lark document with table extraction.
Usage:
/lark read FVcbwfYBFii6ZmkXVUNlj4yFgPg
/lark read https://xxx.larksuite.com/wiki/FVcbwfYBFii6ZmkXVUNlj4yFgPg
What it does:
- Fetches all document blocks via Lark Blocks API
- Parses block types: Page, Text, Heading, Table, TableCell
- Reconstructs table content from nested blocks
- Outputs as formatted markdown
/lark message <chat-id> <content>
Send a message to a Lark chat.
Usage:
/lark message oc_xxx "Hello team!"
Implementation:
Uses MCP tool mcp__tn-lark__im_v1_message_create
/lark lookup <email>
Look up user information by email.
Usage:
/lark lookup user@example.com
Implementation:
Uses MCP tool mcp__tn-lark__contact_v3_user_batchGetId
MCP Tools Available
| Tool | Purpose |
|---|---|
mcp__tn-lark__docx_v1_document_rawContent |
Get document text |
mcp__tn-lark__im_v1_message_create |
Send message |
mcp__tn-lark__im_v1_message_list |
Get chat history |
mcp__tn-lark__im_v1_chat_list |
List chats |
mcp__tn-lark__im_v1_chatMembers_get |
Get chat members |
mcp__tn-lark__contact_v3_user_batchGetId |
Look up users |
mcp__tn-lark__wiki_v2_space_getNode |
Get wiki node info |
mcp__tn-lark__wiki_v1_node_search |
Search wiki |
mcp__tn-lark__bitable_v1_* |
Lark Base operations |
Document Parsing Details
Block Types
| Type | ID | Description | Editable |
|---|---|---|---|
| Page | 1 | Document root | Container |
| Text | 2 | Plain text paragraph | ✅ |
| Heading 1-9 | 3-11 | Section headings | ✅ |
| Bullet | 12 | Unordered list item | ✅ |
| Ordered | 13 | Ordered list item | ✅ |
| Code | 14 | Code block | ✅ |
| Quote | 15 | Quote block | ✅ |
| Todo | 17 | Checkbox item | ✅ |
| Callout | 19 | Callout box | Container |
| Divider | 22 | Horizontal line | N/A |
| Grid | 24 | Multi-column layout | Container |
| Grid Column | 25 | Column in grid | Container |
| Image | 27 | Image block | replace_image |
| Table | 31 | Table container | Table ops |
| TableCell | 32 | Table cell | Container |
Table Parsing
Tables have nested structure:
Table (31)
└── cells: ["cell_block_id_1", "cell_block_id_2", ...]
└── TableCell (32)
└── children: ["text_block_id"]
└── Text (2)
└── elements[].text_run.content
Implementation Example
source "$SKILL_DIR/scripts/lark-auth.sh"
# Get all blocks (handles pagination)
BLOCKS=$(get_all_document_blocks "$DOC_ID")
# Parse with jq
echo "$BLOCKS" | jq -r '
(map({key: .block_id, value: .}) | from_entries) as $blocks |
.[] |
if .block_type == 3 then
"## " + (.heading.elements[0].text_run.content // "")
elif .block_type == 2 then
(.text.elements // [] | map(.text_run.content // "") | join(""))
elif .block_type == 31 then
"| " + (
.table.cells |
map($blocks[.] | .block.children // [] |
map($blocks[.].text.elements[0].text_run.content // "") |
join(" ")
) |
join(" | ")
) + " |"
else
empty
end
'
Interactive Card Formatting
When sending messages via mcp__tn-lark__im_v1_message_create with msg_type: "interactive":
Column Set (Table-like Layout)
Use column_set for table-like data. Markdown tables don't render properly in cards.
{
"tag": "column_set",
"flex_mode": "none",
"background_style": "grey",
"columns": [
{
"tag": "column",
"width": "weighted",
"weight": 3,
"elements": [{"tag": "markdown", "content": "**Task**"}]
},
{
"tag": "column",
"width": "weighted",
"weight": 1,
"elements": [{"tag": "markdown", "content": "**Owner**"}]
}
]
}
Common Components
| Component | Tag | Use |
|---|---|---|
| Text/Markdown | markdown |
Rich text content |
| Divider | hr |
Horizontal line |
| Button | action with button |
Clickable links |
| Columns | column_set |
Table-like layouts |
Card Header
{
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": "Title Here"}
},
"elements": [...]
}
Header templates: blue, green, orange, red, purple, grey
Example: Sprint Update Card
{
"header": {"template": "blue", "title": {"tag": "plain_text", "content": "Sprint Update"}},
"elements": [
{"tag": "markdown", "content": "**Summary text here**"},
{"tag": "hr"},
{"tag": "column_set", "columns": [...]},
{"tag": "action", "actions": [
{"tag": "button", "text": {"tag": "plain_text", "content": "View"}, "type": "primary", "url": "https://..."}
]}
]
}
Limitations
Reading:
- Link previews - Embedded links show as
link_previewblocks - Images - Not downloaded; shows placeholder
- Complex formatting - Bold, italic, colors simplified to plain text
- Comments - Not included in block API response
Editing:
- Rate limits - 3 edits/sec per document, 3 API calls/sec per app
- Batch size - Max 200 blocks per batch request
- Wiki permission - App must be wiki space member with edit permission
- Transient errors - Use retry with exponential backoff for 9499 errors
Bitable API Pitfalls (from harvis-3or, harvis-a81)
DateTime Filters
- Filter value format:
["ExactDate", "<millisecond_timestamp>"]— raw timestamps don't work isGreaterEqual/isLessEqualoperators don't work on DateTime fields- Use
isGreater/isLesswith +/-1ms offset instead
Eventual Consistency
- Reads don't immediately see just-created rows
- Causes duplicate creation if you read-then-write without guards
- Workaround: Maintain an in-memory cache and check before creating
- Use
asyncio.Lock(or equivalent) to prevent concurrent write races
Field Type Surprises
- Text fields return as rich-text arrays, not plain strings
- Date fields accept millisecond timestamps for writes
- Always validate API response shapes before building — they differ from docs
Record Search
POST /bitable/v1/apps/:app/tables/:table/records/searchfor queries- Filter conditions:
{"field_name": "x", "operator": "is", "value": ["y"]} - Batch create:
POST /bitable/v1/apps/:app/tables/:table/records/batch_create
Related
/pjmskill - Uses this for reading sprint docs- Team members in
shared/team/members.yamlhave Lark IDs