Back to skills
SkillHub ClubWrite Technical DocsFull StackTech Writer

feishu-md2blocks

Insert rich Markdown content (including tables) into Feishu documents. Use when feishu_doc write/append fails with tables, or when inserting complex formatted content (tables, code blocks, nested lists) into an existing document at a specific position.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,070
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
A92.0

Install command

npx @skill-hub/cli install openclaw-skills-feishu-md2blocks

Repository

openclaw/skills

Skill path: skills/deadblue22/feishu-md2blocks

Insert rich Markdown content (including tables) into Feishu documents. Use when feishu_doc write/append fails with tables, or when inserting complex formatted content (tables, code blocks, nested lists) into an existing document at a specific position.

Open repository

Best for

Primary workflow: Write Technical Docs.

Technical facets: Full Stack, Tech Writer.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install feishu-md2blocks into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding feishu-md2blocks to shared team environments
  • Use feishu-md2blocks for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: feishu-md2blocks
description: Insert rich Markdown content (including tables) into Feishu documents. Use when feishu_doc write/append fails with tables, or when inserting complex formatted content (tables, code blocks, nested lists) into an existing document at a specific position.
---

# Feishu Markdown to Blocks

Insert Markdown content—including tables—into Feishu documents via the block convert + descendant API.

## When to Use

- `feishu_doc` `write` replaces the entire document; use this to **insert** content at a position
- `feishu_doc` `create_table_with_values` has limitations for larger tables
- You need to insert tables, code blocks, or complex nested content into an existing doc

## Usage

```bash
# Insert from file (appends to document end)
python3 <skill_dir>/scripts/md2blocks.py <doc_token> content.md

# Insert from stdin
echo "| A | B |\n|---|---|\n| 1 | 2 |" | python3 <skill_dir>/scripts/md2blocks.py <doc_token> -

# Insert after a specific block
python3 <skill_dir>/scripts/md2blocks.py <doc_token> content.md --after <block_id>

# Replace all content
python3 <skill_dir>/scripts/md2blocks.py <doc_token> content.md --replace
```

## How It Works

1. Calls `POST /docx/v1/documents/blocks/convert` to convert Markdown → block structures
2. Removes `merge_info` from table blocks (read-only field that causes insertion errors)
3. Calls `POST /docx/v1/documents/{doc}/blocks/{parent}/descendant` to insert blocks

The descendant API handles nested structures (tables with cells containing text) that the simpler `/children` API cannot.

## Position Control

The `--after <block_id>` option inserts content right after the specified block. The script finds the block's index automatically.

**Key detail:** The `/descendant` API's `index` parameter **must be in the request body**, not as a URL query parameter. Passing `?index=N` in the URL is silently ignored (content appends to end). The script handles this correctly.

## Supported Markdown

Text, headings (h1-h9), bullet lists, ordered lists, code blocks, quotes, tables, todo items, dividers.

## Limitations

- Images in Markdown are not automatically uploaded; they require separate upload + patch steps
- Max 1000 blocks per insert call; split large documents if needed
- Requires `docx:document.block:convert` permission on the Feishu app
- Document edit rate limit: 3 ops/sec per document

## Reference

For complete block-level API reference, see the **feishu-block-ops** skill which covers:
- All block APIs (create/read/update/delete/batch)
- Block type reference, text element types
- Table operation patterns (batch edit, merge cells)
- Common patterns and gotchas


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "deadblue22",
  "slug": "feishu-md2blocks",
  "displayName": "Feishu Md2blocks",
  "latest": {
    "version": "1.0.0",
    "publishedAt": 1772605578855,
    "commit": "https://github.com/openclaw/skills/commit/2867ee1e7a51b6ba21c99b647ebc921301080443"
  },
  "history": []
}

```

### scripts/md2blocks.py

```python
#!/usr/bin/env python3
"""
Convert Markdown to Feishu document blocks and insert into a document.

Usage:
    python3 md2blocks.py <doc_token> <markdown_file_or_-> [--after <block_id>] [--replace]

Arguments:
    doc_token       Target document token
    markdown_file   Path to markdown file, or "-" to read from stdin
    --after ID      Insert after this block ID (appends to root if omitted)
    --replace       Delete all existing content before inserting

Reads Feishu app credentials from ~/.openclaw/openclaw.json
"""
import json, sys, os, argparse, urllib.request, urllib.error

def get_credentials():
    config_path = os.path.expanduser("~/.openclaw/openclaw.json")
    with open(config_path) as f:
        d = json.load(f)
    c = d["channels"]["feishu"]
    return c.get("appId", ""), c.get("appSecret", "")

def get_token(app_id, app_secret):
    payload = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode()
    req = urllib.request.Request(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        data=payload, headers={"Content-Type": "application/json"}, method="POST"
    )
    return json.loads(urllib.request.urlopen(req).read())["tenant_access_token"]

def api_call(token, method, url, body=None):
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8"}
    data = json.dumps(body).encode() if body else None
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        return json.loads(urllib.request.urlopen(req).read())
    except urllib.error.HTTPError as e:
        error_body = e.read().decode()
        print(f"HTTP {e.code}: {error_body[:500]}", file=sys.stderr)
        sys.exit(1)

def convert_markdown(token, markdown):
    url = "https://open.feishu.cn/open-apis/docx/v1/documents/blocks/convert"
    resp = api_call(token, "POST", url, {"content_type": "markdown", "content": markdown})
    if resp.get("code") != 0:
        print(f"Convert error: {resp.get('msg')}", file=sys.stderr)
        sys.exit(1)
    return resp["data"]

def clean_blocks(blocks):
    """Remove merge_info from table blocks (required before insertion)."""
    for block in blocks:
        if block.get("block_type") == 31 and "table" in block:
            prop = block["table"].get("property", {})
            if "merge_info" in prop:
                del prop["merge_info"]
    return blocks

def get_doc_children(token, doc_token):
    url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_token}/blocks/{doc_token}"
    resp = api_call(token, "GET", url)
    if resp.get("code") != 0:
        return []
    return resp.get("data", {}).get("block", {}).get("children", [])

def delete_blocks_by_range(token, doc_token, start_index, end_index):
    """Delete blocks by index range [start_index, end_index)."""
    url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_token}/blocks/{doc_token}/children/batch_delete"
    resp = api_call(token, "DELETE", url, {"start_index": start_index, "end_index": end_index})
    if resp.get("code") != 0:
        print(f"Delete error: {resp.get('msg')}", file=sys.stderr)
    return resp

def insert_descendants(token, doc_token, parent_id, children_ids, descendants, index=None):
    """Insert blocks using the descendant API (supports tables and nested blocks).
    
    IMPORTANT: index must be in the request body, NOT as a query parameter.
    Passing index as ?index=N in the URL is silently ignored by the API.
    """
    url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_token}/blocks/{parent_id}/descendant"
    body = {
        "children_id": children_ids,
        "descendants": descendants
    }
    if index is not None:
        body["index"] = index
    resp = api_call(token, "POST", url, body)
    if resp.get("code") != 0:
        print(f"Insert error (code={resp.get('code')}): {resp.get('msg')}", file=sys.stderr)
        sys.exit(1)
    children = resp.get("data", {}).get("children", [])
    print(f"SUCCESS: Inserted {len(children)} top-level blocks")
    return children

def find_block_index(token, doc_token, after_block_id):
    children = get_doc_children(token, doc_token)
    for i, child_id in enumerate(children):
        if child_id == after_block_id:
            return i + 1
    print(f"WARNING: Block {after_block_id} not found, appending", file=sys.stderr)
    return None

def main():
    parser = argparse.ArgumentParser(description="Convert Markdown to Feishu doc blocks")
    parser.add_argument("doc_token", help="Target document token")
    parser.add_argument("markdown_file", help="Markdown file path or '-' for stdin")
    parser.add_argument("--after", help="Insert after this block ID")
    parser.add_argument("--replace", action="store_true", help="Replace all existing content")
    args = parser.parse_args()

    if args.markdown_file == "-":
        markdown = sys.stdin.read()
    else:
        with open(args.markdown_file) as f:
            markdown = f.read()

    if not markdown.strip():
        print("ERROR: Empty markdown content", file=sys.stderr)
        sys.exit(1)

    app_id, app_secret = get_credentials()
    token = get_token(app_id, app_secret)

    # Convert markdown to blocks
    convert_data = convert_markdown(token, markdown)
    blocks = clean_blocks(convert_data.get("blocks", []))
    first_level_ids = convert_data.get("first_level_block_ids", [])

    print(f"Converted: {len(first_level_ids)} top-level blocks, {len(blocks)} total blocks")

    # Handle --replace
    if args.replace:
        children = get_doc_children(token, args.doc_token)
        if children:
            print(f"Deleting {len(children)} existing blocks...")
            delete_blocks_by_range(token, args.doc_token, 0, len(children))

    # Determine insertion index
    index = None
    if args.after:
        index = find_block_index(token, args.doc_token, args.after)

    # Insert using descendant API
    insert_descendants(token, args.doc_token, args.doc_token, first_level_ids, blocks, index)

if __name__ == "__main__":
    main()

```

feishu-md2blocks | SkillHub