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, commit765f5a6). 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’sfile: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
- See the finish line: what a social-content workbench looks like
- Step 0 · The scene enters: twelve walls inside one sentence
- Step 1 · Decide not to write an agent: treat OpenClaw as the engine
- Step 2 · Three entries share one persona prefix
- Step 3 · Four-layer prompt stack; the resident layer does not write skill names
- Step 4 · sync: workspace is the script, the project root is the worksite
- Step 5 · SKILL.md contract: 200-line SOP + scripts do not enter the prompt
- Step 6 · outputs gate: content is a project
- Step 7 · manifest: a thin index, do not orally transmit the full text
- Step 8 · TURN_REMINDER: recency against instruction decay
- Step 9 · content_guard: fail-closed before a real post
- Step 10 · persona_gate: persona only reminds, never blocks
- Step 11 · SSE: a dropped connection does not kill; results land on disk
- Step 12 · dual lock + uuid5: sessions do not cross-contaminate, and they survive overnight
- Step 13 · session_heal: upstream stored thinking and lost the signature
- Step 14 · Login and
--exec: enter the real platforms - Step 15 · doctor / ping: turn the environment into product commands
- 🎬 Full replay: what that one sentence actually ran
- Appendix A · Acceptance assertions
- Appendix B · Twelve ways it most easily blows up
- Appendix C · Source index
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:
- It does not reason itself—reasoning is what that
openclaw --profile easelon the machine is doing. - The artifact is for humans to see and to post, not for a compiler to see.
- One topic must be one folder; later revisions, retries, and publishes cannot be scattered in chat.
- Accounts have personas, and several accounts will run sessions in parallel—a global USER.md will step on itself.
- Words that have been posted cannot be deleted, so secrets and internal domains must be scanned deterministically.
- 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.
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.
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).
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).
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.
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):
- Copy SKILL and shared into the workspace
- Append the project-root absolute path into AGENTS.md (
:77-93) ln -sprofiles, outputs (rm first then ln, against cycles,:107)- Empty MEMORY.md, delete USER.md
- Process environment
EASEL_ROOT=project root(manifest.py:58-61explains 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.
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.
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 haveallow_system=Trueand 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).
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.
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.
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,
.envliteral 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.
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.
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):
asyncio.create_task(supervisor()); the client generator onlyforwards the queue- Child-process environment opens
OPENCLAW_RAW_STREAM; tail each turn’s jsonl to push tokens _save_turnatomically writesoutputs/_sessions/<sk>.json- Disconnect does not
terminate; only/api/chat/stopkills the process - 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.
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.Lockkeyed 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.
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.
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:
- Save a browser profile per platform (
~/.easel-browser-profiles) - After the publish API’s second confirmation, assemble
--exec(web/app.py:1996-2053) - Douyin SMS wall: async + a state file + the frontend inputs the verification code (
:2040-2045) - Before publish must pass content_guard; persona only reminds
- 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.
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.
🎬 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.”
- Frontend
streamChat,turnIdwritten into sessionStorage (App.tsx:197-198). _chat_message= persona prefix + user original + attachment list (none) + TURN_REMINDER (web/app.py:798-805).- Supervisor first
_save_turn(running), then_heal_openclaw_session, then grabs the asyncio lock + flock. openclaw --profile easel agent --session-id <uuid5> --thinking low --timeout 7200.- The model reads SOUL / AGENTS / CONTEXT; TURN_REMINDER requires looking up the skill library first → hits
xhs-note-creator(produce) and laterskill-xhs-publisher(publish). Crossing two layers, a short Plan first. cdto the project root. Distill identity/style/audience/preferences/memory undereasel-profiles/科技数码达人/, do not read platforms.md (produce-layer rule).- 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.pyrefuses any generic name. manifest.py record --layer produce ...;meta --kind xhs-note --status ready.- Before publish
skill-persona-check+persona_gate(warn also continues);content_guardscans title / body / tags. xhs_publish.py publish --images ... --exec. On success, manifestpublished; calendar / publish-log leave a trace.- Raw stream
assistant_message_end→_save_turn(done)→ SSEdone. If the proxy already cut SSE, the frontend/api/chat/lastcan still retrieve it. - The content library refreshes, reads
.easel.jsonfor 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).
Appendix A · Acceptance assertions
After the matching step, you should be able to tick:
- [ ]
openclaw --profile easelcan run; gateway:18789/healthz200 - [ ] 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_ROOTis set - [ ] SKILL frontmatter is only name/description/layer
- [ ]
outputs/test/a.pngis refused by the gate - [ ] Manifest
summaryis 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-idis unchanged - [ ] History with unsigned thinking is sanitized
- [ ] doctor can point out missing ffmpeg / missing dist / missing key
Appendix B · Twelve ways it most easily blows up
- Handwrite the loop. You will spend time on tool parsing, not on SOP and gates.
- Global USER.md. Multi-account steps on itself on day one.
- Run scripts inside the workspace.
__file__is one layer short; artifacts write to~/.openclaw. - Trust
openclaw.json5. It itself says it is never applied by setup (openclaw/openclaw.json5:2-5). - Treat finished artifacts as chat attachments. Cannot review; cannot pick media in the publish center.
- Manifest copies full text. The index gets dirty; downstream still goes to “distill.”
- Rely only on the system prompt against leaks. The real-post path must scan deterministically.
- Persona score blocks publish. Creators will turn the product off.
- SSE disconnect kills the process. Every produce-layer task fails.
- Only add an asyncio lock. The second browser tab still takeovers.
- Only pass
--session-key. Overnight, history evaporates. - Xiaohongshu defaults to auto-post. Account risk control. Default preview; a human clicks confirm, then
--exec.
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 |