English lecture · source-traced

Build a social-content workbench from scratch (Easel lineage) · full development process

**What this tutorial teaches**: not how to write yet another ReAct loop, but how to build a **host that treats someone else’s agent CLI as the engine, specialized in producing real social-media content that can actually be posted**—the Easel kind (`ZJU-REAL/Easel`, Apache-2.0, commit `765f5a6`). **The most counter-intuitive point**: on this route, **you do not write a single line of the agent loop**. What you write i

EN
lecture
source
file:line
no
embellishment
中文
sibling

What this tutorial teaches: not how to write yet another ReAct loop, but how to build a host that treats someone else’s agent CLI as the engine, specialized in producing real social-media content that can actually be posted—the Easel kind (ZJU-REAL/Easel, Apache-2.0, commit 765f5a6). The most counter-intuitive point: on this route, you do not write a single line of the agent loop. What you write is “socket + script + project directory + outbound gate.” How it teaches: follow one real demand from start to finish—“using my tech-digital account, make one Xiaohongshu note and post it.” Every wall you hit, add one part. Source to compare: every part gives Easel’s file:line. Companion reading Easel source analysis and the interactive bench Easel-解析.html. Prerequisite: knowing “agent = the model speaks → software acts for it → the result is fed back to the model” is enough. You do not need to know how to cut video, and you do not need to know how to write OpenClaw.


Contents


Part 1

See the finish line: what a social-content workbench looks like

The boundary between a coding agent and a social-content workbench is not the model. It is these six things:

  1. It does not reason itself—reasoning is what that openclaw --profile easel on the machine is doing.
  2. The artifact is for humans to see and to post, not for a compiler to see.
  3. One topic must be one folder; later revisions, retries, and publishes cannot be scattered in chat.
  4. Accounts have personas, and several accounts will run sessions in parallel—a global USER.md will step on itself.
  5. Words that have been posted cannot be deleted, so secrets and internal domains must be scanned deterministically.
  6. Production may run two hours; browser SSE will be cut by a proxy—the engine must not die with the connection.
flowchart TB
    subgraph SEE["① 创作者看见的"]
        CHAT["对话"]
        LIB["内容库 outputs/"]
        ACC["账号登录"]
        PUB["发布中心"]
    end
    subgraph HOST["② 你要写的宿主"]
        PREFIX["画像前缀 + TURN_REMINDER"]
        SSE["supervisor / forward 拆开"]
        LOCK["asyncio 锁 + flock"]
        GUARD["content_guard + persona_gate"]
        PATH["output_paths 门禁"]
    end
    subgraph PLAY["③ 剧本(文件,可版本化)"]
        SOUL["SOUL.md"]
        AG["AGENTS.md"]
        SK["skills/openclaw/*"]
        PF["profiles/六维 md"]
    end
    subgraph ENG["④ 引擎(不是你的代码)"]
        OC["openclaw CLI + gateway :18789"]
    end
    CHAT --> SSE
    SSE --> PREFIX
    PREFIX --> OC
    OC --> PLAY
    OC -->|cd 项目根跑脚本| PATH
    PATH --> LIB
    GUARD --> PUB
    ACC --> PUB

Compare Easel: the integration package is only 768 lines, web/app.py 2509 lines, 112 skills. The minieasel you build need not have 112 skills, but not one of these six things can be missing, or “can chat” and “can post” will divorce in week one.


Part 2

Step 0 · The scene enters: twelve walls inside one sentence

The user said:

Using my tech-digital account, make one Xiaohongshu note and post it. The topic is “USB4 enclosure pitfalls.”

That sentence looks like one prompt. Split open, it is twelve product problems:

# Wall What happens if you do not add the part
1 Who reasons You start handwriting a while loop; three months later you are still tuning tool parsing
2 Which account Global USER.md; two tabs step on each other’s persona
3 What the note looks like The model writes a WeChat-official-account essay; posted to Xiaohongshu, nobody reads it
4 How the card is rendered The model collages with emoji; it looks like a PowerPoint
5 Where files go output.png dumped at the repo root; next week you cannot find it
6 How planning conclusions reach production The model restates three thousand characters in the conversation; tokens explode, and it still drops
7 After a long conversation it does not use a skill “Naked production from memory”; the script gate is bypassed
8 An API key appears in the copy Posted; cannot be deleted
9 The persona is not quite like Block, or ask? Block and the creator will curse
10 Generation takes 40 minutes SSE is cut; the frontend thinks it is dead; the backend kills it too
11 Two windows, same session OpenClaw takeover; the reply stops at a colon
12 Actually post to Xiaohongshu Risk control, SMS wall, Playwright login state

Each later step takes down one wall.


Part 3

Step 1 · Decide not to write an agent: treat OpenClaw as the engine

Why: loop, tools, session, model provider—OpenClaw already has them. Your differentiation is social-media SOP and actually posting. Writing another loop is competing with upstream for the same job.

Minimum implementation:

cmd = [
    "openclaw", "--profile", "easel",
    "agent", "--agent", "main",
    "--timeout", str(timeout),
    "--message", message,
]
subprocess.run(cmd, cwd=PROJECT_ROOT, env=proxy_env)

Compare: easel/commands/skill.py:91-106, easel/cli.py:103-119. The profile name is written dead, isolated from the user’s other OpenClaw workspaces.

Acceptance: which openclaw succeeds; easel ping can make the agent reply PONG (easel/commands/ping.py:62-70).

Real Easel: the gateway listens on 18789; doctor hits healthz (easel/commands/doctor.py:75-81).


Part 4

Step 2 · Three entries share one persona prefix

Why: if CLI chat, CLI skill, and Web conversation each write their own “who the current user is,” on day three you get “Web has a persona, CLI does not.” Worse is global USER.md: concurrent sessions write the same file.

Rejected design: generate USER.md at sync. Easel’s comment writes clearly that this is the historical design (easel/persona.py:1-6).

Minimum implementation:

def persona_prefix(name: str | None) -> str:
    if name and profile_exists(name):
        return (
            f"我当前使用的画像是「{name}」。"
            f"本会话的账号长期记忆仅使用 profiles/{name}/memory.md,"
            "不要使用工作区全局 MEMORY.md 作为账号记忆。"
        )
    return ""

Compare: easel/persona.py:58-69. Six-dimension file order: identity / style / audience / platforms / preferences / memory (:16-20). What is injected is a pointer, not stuffing six md files’ full text into every message.

CLI chat treats the prefix as an initial message (easel/cli.py:112-117); Web carries it every turn (web/app.py:798-805). After a very long session is compacted, CLI may lose the persona—the comment admits this is a known trade-off (easel/cli.py:112-114).

Acceptance: two concurrent requests carry persona A / B respectively; there is no USER.md in the workspace; global MEMORY.md is empty (openclaw/sync.sh:97-103).


Part 5

Step 3 · Four-layer prompt stack; the resident layer does not write skill names

Why: write 112 skill names into SOUL.md, and retiring one platform means you have to edit the personality file. The resident layer only writes categories: “can do discover / plan / produce / publish / attribute.” Concrete names are left to the SKILL.md that was triggered.

Compare: the full text of docs/prompt-stack.md; SOUL.md itself says “go to the skill library first” (openclaw/workspace/SOUL.md:18); AGENTS.md is what writes the routing rules (openclaw/workspace/AGENTS.md:7).

Minimum implementation (four files, do not add more):

Layer File Only governs
1 SOUL.md Tone, capability categories, do not self-expose outward
2 AGENTS.md Route first, cd to the project root first, look up before asking, only a real artifact counts as done, outbound safety
3 CONTEXT.md Project absolute path (generated, not handwritten)
4 SKILL.md Loaded only when triggered

Acceptance: grepping SOUL.md does not find a concrete name such as xhs-note-creator.


Part 6

Step 4 · sync: workspace is the script, the project root is the worksite

Why: OpenClaw’s cwd is ~/.openclaw/workspace-easel/. If you let it run python skills/... there, you are missing .env, the path is one layer short, and artifacts write into the home directory.

Minimum implementation (compare openclaw/sync.sh):

  1. Copy SKILL and shared into the workspace
  2. Append the project-root absolute path into AGENTS.md (:77-93)
  3. ln -s profiles, outputs (rm first then ln, against cycles, :107)
  4. Empty MEMORY.md, delete USER.md
  5. Process environment EASEL_ROOT=project root (manifest.py:58-61 explains why walking up from __file__ will be wrong)

AGENTS.md writes dead: it is forbidden to run project scripts from the workspace’s shared copy (openclaw/workspace/AGENTS.md:8-9).

Acceptance: the pwd before the agent’s first script is the project root; test -f .env && test -d skills/shared/scripts is true.


Part 7

Step 5 · SKILL.md contract: 200-line SOP + scripts do not enter the prompt

Why: writing ffmpeg commands into the system prompt is both expensive and easy to go stale. The SOP writes “how to do it”; pixel operations live in scripts/; domain knowledge lives in references/.

Minimum implementation: one skill directory:

---
name: xhs-note-creator
description: >
  小红书内容总入口:……当用户说「做小红书笔记」时使用。
  仅渲染卡片用 card-xiaohongshu;其它平台用 social-content。
layer: produce
---

description must contain the trigger phrasing and the adjacent boundary—that is the entire router (docs/SKILL-SPEC.md:73-76). There is no Python if intent == ....

Acceptance: python scripts/validate_skills.py passes; SKILL.md < 200 lines.

Real Easel also has validate_skill_commands.py: compare python commands in the SKILL body against the script argparse, against documentation drift.


Part 8

Step 6 · outputs gate: content is a project

Why: the model likes to write outputs/test/out.png. Next week you have 40 tests.

Minimum implementation: every write-to-disk script first passes validate_output_path (skills/shared/scripts/output_paths.py:41-75):

  • Must be under outputs/
  • A content path is at least outputs/<topic>/<file>
  • The topic cannot be a word in the generic-name set
  • Names starting with _ are system directories; they must have allow_system=True and be on the whitelist

Finished artifacts go at the project root of the topic; intermediates go into assets/; metadata is only .easel.json.

Acceptance: attempting to write outputs/xhs/a.png raises OutputPathError (the generic name xhs is in GENERIC_PROJECT_NAMES, :25-29).


Part 9

Step 7 · manifest: a thin index, do not orally transmit the full text

Why: if crossing “plan → produce → publish” relies on the model restating the full script in the conversation, it is both expensive and lossy.

Minimum implementation: manifest.py record --topic USB4硬盘盒踩坑 --layer produce --skill xhs-note-creator --outputs card_1.png,note.md --summary "5 张卡,钩子是接口兼容".

Downstream latest --layer produce only gets paths + one sentence of conclusion, then goes and reads the files (docs/SKILL-SPEC.md:157-159). Failures also --status failed, so you can resume from a breakpoint.

Acceptance: .easel.json’s steps[].outputs are relative paths, and do not embed copy body.


Part 10

Step 8 · TURN_REMINDER: recency against instruction decay

Why: on turn 1 the model will go read the SKILL; on turn 12 the user says “tweak the title again,” and the model edits from memory, bypassing card-design and de-AI-ifying.

Minimum implementation: only at the conversation entry, splice a short internal reminder onto the end of the user message (easel/persona.py:72-99). Do not add it when running a skill alone. The frontend displays only the user’s original text.

Acceptance: capture a --message the Web sends to openclaw; the end can see “before acting this turn, look up the skill library first,” and this paragraph does not appear in the chat bubble.


Part 11

Step 9 · content_guard: fail-closed before a real post

Why: a prompt that says “do not output an API key” cannot stop the model from pasting an error or command output into the copy. Posting is irreversible.

Minimum implementation: before the publish script’s --exec, call guard_or_die(text).

  • BLOCK (secrets, internal domains, proxy IPs, internal paths, env names, .env literal values) → exit 7
  • WARN (“generated by AI,” model names) → print a reminder, allow

Compare: content_guard.py:11-15, 97-105. Dual-path scan: regex + .env literal values (:141-176). Hits in the report are redacted.

Acceptance: copy containing a secret starting with sk-, a real post’s exit code is 7; a paper explainer whose copy contains “Claude,” exit code 0.


Part 12

Step 10 · persona_gate: persona only reminds, never blocks

Why: a secret leak is an incident; “this does not quite sound like a digital blogger” is a creative judgment. The creator already clicked publish; if you then block on a score, the product becomes a supervisor.

Minimum implementation: LLM scores → persona_gate.py check --score N → always publish_allowed: true, exit code 0 (persona_gate.py:10-11, 42-54). Below 80 is only warn, showing the deviation points. AGENTS.md stipulates that if the user has explicitly asked to post, continue (openclaw/workspace/AGENTS.md:78).

Acceptance: score=12 still exit 0; the manifest can record this warn.


Part 13

Step 11 · SSE: a dropped connection does not kill; results land on disk

Why: the produce layer times out at 7200 seconds (easel/timeouts.py:11). Browsers and reverse proxies will not live that long. If SSE disconnect kills the child process, the video will never finish.

Minimum implementation (compare web/app.py:1024-1401):

  1. asyncio.create_task(supervisor()); the client generator only forwards the queue
  2. Child-process environment opens OPENCLAW_RAW_STREAM; tail each turn’s jsonl to push tokens
  3. _save_turn atomically writes outputs/_sessions/<sk>.json
  4. Disconnect does not terminate; only /api/chat/stop kills the process
  5. After the frontend disconnects, poll /api/chat/last

Acceptance: close the tab during generation; the process is still there; reopen the page and you can retrieve the complete reply.


Part 14

Step 12 · dual lock + uuid5: sessions do not cross-contaminate, and they survive overnight

Wall 11: two windows, same session → EmbeddedAttemptSessionTakeoverError, the reply stops at a colon (web/app.py:808-811).

Minimum implementation:

  • In-process asyncio.Lock keyed by session-key
  • Cross-process fcntl.flock (web/app.py:841-896)
  • Frontend BroadcastChannel occupancy probe (App.tsx:55-125)

Wall “forgot overnight”: OpenClaw --session-key binding expires in about 24h (web/app.py:823-827).

Minimum implementation: uuid.uuid5(fixed namespace, web_session_id) pins --session-id (:828-833).

Acceptance: two tabs collide on the same session; the second sees queued / please switch windows, not rc=1; 25 hours later the same sessionId still points at the same jsonl.


Part 15

Step 13 · session_heal: upstream stored thinking and lost the signature

Why: this is not your bug, but what the user sees is “Session history or replay state is invalid.” When OpenClaw stored thinking blocks it dropped the signature; Bedrock replay validation fails (scripts/session_heal.py:3-6).

Minimum implementation: before every spawn, delete thinking / redacted_thinking blocks and empty messages; default thinking gear low (web/app.py:51-71). Failure does not throw; best-effort.

Acceptance: a jsonl containing unsigned thinking, after sanitize, the next turn is no longer 400.


Part 16

Step 14 · Login and `--exec`: enter the real platforms

Why: if you only give copy, the creator still has to paste it into the creator center—the workbench breaks at the last mile. Easel chooses Playwright / biliup for real posts, and warns in the README about Xiaohongshu risk control.

Minimum implementation:

  1. Save a browser profile per platform (~/.easel-browser-profiles)
  2. After the publish API’s second confirmation, assemble --exec (web/app.py:1996-2053)
  3. Douyin SMS wall: async + a state file + the frontend inputs the verification code (:2040-2045)
  4. Before publish must pass content_guard; persona only reminds
  5. Image / video mutual exclusion, some platforms must carry media—hard constraints in code, do not rely on the model remembering

Acceptance: dry-run lists what will be posted and does not touch the network; --exec on a BLOCK hit is exit 7.


Part 17

Step 15 · doctor / ping: turn the environment into product commands

Why: the dependency chain is Python + Node 22.19 + FFmpeg + Playwright + frontend dist + gateway + skills sync + API Key. Any missing ring, the user will only say “it does not work.”

Minimum implementation: doctor static checkmarks (easel/commands/doctor.py:143-207), ping actually makes the agent speak (ping.py). Key check rejects the REPLACE_ME placeholder.

Acceptance: unplug ffmpeg, doctor red; after it is configured, ping’s two steps all green.


Part 18

🎬 Full replay: what that one sentence actually ran

On the Web the user picks persona “tech-digital creator,” types “make one Xiaohongshu: USB4 enclosure pitfalls, when done post it directly.”

  1. Frontend streamChat, turnId written into sessionStorage (App.tsx:197-198).
  2. _chat_message = persona prefix + user original + attachment list (none) + TURN_REMINDER (web/app.py:798-805).
  3. Supervisor first _save_turn(running), then _heal_openclaw_session, then grabs the asyncio lock + flock.
  4. openclaw --profile easel agent --session-id <uuid5> --thinking low --timeout 7200.
  5. The model reads SOUL / AGENTS / CONTEXT; TURN_REMINDER requires looking up the skill library first → hits xhs-note-creator (produce) and later skill-xhs-publisher (publish). Crossing two layers, a short Plan first.
  6. cd to the project root. Distill identity/style/audience/preferences/memory under easel-profiles/科技数码达人/, do not read platforms.md (produce-layer rule).
  7. Per the SKILL, clarify form (default image+text), write the long copy, de-AI-ify, walk card-design to render cards into outputs/USB4硬盘盒踩坑/. output_paths.py refuses any generic name.
  8. manifest.py record --layer produce ...; meta --kind xhs-note --status ready.
  9. Before publish skill-persona-check + persona_gate (warn also continues); content_guard scans title / body / tags.
  10. xhs_publish.py publish --images ... --exec. On success, manifest published; calendar / publish-log leave a trace.
  11. Raw stream assistant_message_end → _save_turn(done) → SSE done. If the proxy already cut SSE, the frontend /api/chat/last can still retrieve it.
  12. The content library refreshes, reads .easel.json for the display header; the cover is the first card.

If any layer in the middle fails: the manifest records failed, mid-way files stay in assets/, an empty shell is not treated as a delivery (openclaw/workspace/AGENTS.md:12, 62).


Part 19

Appendix A · Acceptance assertions

After the matching step, you should be able to tick:

  • [ ] openclaw --profile easel can run; gateway :18789/healthz 200
  • [ ] All three entries’ personas go through persona_prefix; no USER.md
  • [ ] SOUL.md contains no concrete skill names
  • [ ] Script cwd is the project root; EASEL_ROOT is set
  • [ ] SKILL frontmatter is only name/description/layer
  • [ ] outputs/test/a.png is refused by the gate
  • [ ] Manifest summary is one line; the body is in the file
  • [ ] Web conversation messages have TURN_REMINDER at the end; the bubble does not
  • [ ] Pending copy containing sk- is exit 7
  • [ ] Persona score=10 still allows publish
  • [ ] Close the tab; the openclaw process is still there; last turn can be retrieved
  • [ ] Two windows, same session: the second queues or is refused, does not crash
  • [ ] The same web id, a day later, --session-id is unchanged
  • [ ] History with unsigned thinking is sanitized
  • [ ] doctor can point out missing ffmpeg / missing dist / missing key

Part 20

Appendix B · Twelve ways it most easily blows up

  1. Handwrite the loop. You will spend time on tool parsing, not on SOP and gates.
  2. Global USER.md. Multi-account steps on itself on day one.
  3. Run scripts inside the workspace. __file__ is one layer short; artifacts write to ~/.openclaw.
  4. Trust openclaw.json5. It itself says it is never applied by setup (openclaw/openclaw.json5:2-5).
  5. Treat finished artifacts as chat attachments. Cannot review; cannot pick media in the publish center.
  6. Manifest copies full text. The index gets dirty; downstream still goes to “distill.”
  7. Rely only on the system prompt against leaks. The real-post path must scan deterministically.
  8. Persona score blocks publish. Creators will turn the product off.
  9. SSE disconnect kills the process. Every produce-layer task fails.
  10. Only add an asyncio lock. The second browser tab still takeovers.
  11. Only pass --session-key. Overnight, history evaporates.
  12. Xiaohongshu defaults to auto-post. Account risk control. Default preview; a human clicks confirm, then --exec.

Part 21

Appendix C · Source index

Step Main files
1 Engine socket easel/cli.py, easel/commands/skill.py
2 Persona easel/persona.py
3 Prompt stack docs/prompt-stack.md, openclaw/workspace/*.md
4 sync openclaw/sync.sh
5 SKILL docs/SKILL-SPEC.md
6 Paths skills/shared/scripts/output_paths.py
7 manifest skills/shared/scripts/manifest.py
8 Reminder easel/persona.py:72-99
9 Outbound skills/shared/scripts/content_guard.py
10 Persona skills/shared/scripts/persona_gate.py
11–13 Conversation web/app.py:808-1415, scripts/session_heal.py
14 Publish web/app.py:1996-2068
15 Environment easel/commands/doctor.py, ping.py
Timeouts easel/timeouts.py
Frontend session web/frontend/src/App.tsx
↑