# 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 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](./项目分析/Easel-源码分析.html)* and the interactive bench [Easel-解析.html](./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](#end)
- [Step 0 · The scene enters: twelve walls inside one sentence](#s0)
- [Step 1 · Decide not to write an agent: treat OpenClaw as the engine](#s1)
- [Step 2 · Three entries share one persona prefix](#s2)
- [Step 3 · Four-layer prompt stack; the resident layer does not write skill names](#s3)
- [Step 4 · sync: workspace is the script, the project root is the worksite](#s4)
- [Step 5 · SKILL.md contract: 200-line SOP + scripts do not enter the prompt](#s5)
- [Step 6 · outputs gate: content is a project](#s6)
- [Step 7 · manifest: a thin index, do not orally transmit the full text](#s7)
- [Step 8 · TURN_REMINDER: recency against instruction decay](#s8)
- [Step 9 · content_guard: fail-closed before a real post](#s9)
- [Step 10 · persona_gate: persona only reminds, never blocks](#s10)
- [Step 11 · SSE: a dropped connection does not kill; results land on disk](#s11)
- [Step 12 · dual lock + uuid5: sessions do not cross-contaminate, and they survive overnight](#s12)
- [Step 13 · session_heal: upstream stored thinking and lost the signature](#s13)
- [Step 14 · Login and `--exec`: enter the real platforms](#s14)
- [Step 15 · doctor / ping: turn the environment into product commands](#s15)
- [🎬 Full replay: what that one sentence actually ran](#replay)
- [Appendix A · Acceptance assertions](#a1)
- [Appendix B · Twelve ways it most easily blows up](#a2)
- [Appendix C · Source index](#a3)

---

<h2 id="end">See the finish line: what a social-content workbench looks like</h2>

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.

```mermaid
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.

---

<h2 id="s0">Step 0 · The scene enters: twelve walls inside one sentence</h2>

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.

---

<h2 id="s1">Step 1 · Decide not to write an agent: treat OpenClaw as the engine</h2>

**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**:

```python
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`).

---

<h2 id="s2">Step 2 · Three entries share one persona prefix</h2>

**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**:

```python
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`).

---

<h2 id="s3">Step 3 · Four-layer prompt stack; the resident layer does not write skill names</h2>

**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`.

---

<h2 id="s4">Step 4 · sync: workspace is the script, the project root is the worksite</h2>

**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.

---

<h2 id="s5">Step 5 · SKILL.md contract: 200-line SOP + scripts do not enter the prompt</h2>

**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:

```yaml
---
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.

---

<h2 id="s6">Step 6 · outputs gate: content is a project</h2>

**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`).

---

<h2 id="s7">Step 7 · manifest: a thin index, do not orally transmit the full text</h2>

**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.

---

<h2 id="s8">Step 8 · TURN_REMINDER: recency against instruction decay</h2>

**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.

---

<h2 id="s9">Step 9 · content_guard: fail-closed before a real post</h2>

**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.

---

<h2 id="s10">Step 10 · persona_gate: persona only reminds, never blocks</h2>

**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.

---

<h2 id="s11">Step 11 · SSE: a dropped connection does not kill; results land on disk</h2>

**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 `forward`s 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.

---

<h2 id="s12">Step 12 · dual lock + uuid5: sessions do not cross-contaminate, and they survive overnight</h2>

**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.

---

<h2 id="s13">Step 13 · session_heal: upstream stored thinking and lost the signature</h2>

**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.

---

<h2 id="s14">Step 14 · Login and `--exec`: enter the real platforms</h2>

**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.

---

<h2 id="s15">Step 15 · doctor / ping: turn the environment into product commands</h2>

**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.

---

<h2 id="replay">🎬 Full replay: what that one sentence actually ran</h2>

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`).

---

<h2 id="a1">Appendix A · Acceptance assertions</h2>

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

---

<h2 id="a2">Appendix B · Twelve ways it most easily blows up</h2>

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`.

---

<h2 id="a3">Appendix C · Source index</h2>

| 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` |
