English lecture · source-traced

Build an “AI coworker” from scratch (OpenWorker lineage) · full development process

**What this tutorial teaches**: not how to write yet another “terminal agent that writes code,” but how to build an **AI coworker that can run unattended, across apps, ask before it acts, and hand you a finished artifact when it is done**—the OpenWorker kind (open-sourced by Andrew Ng’s team, package name `coworker`). **How it teaches**: same as this repository’s *Build a general-task agent from scratch (Manus lineag

EN
lecture
source
file:line
no
embellishment
中文
sibling

What this tutorial teaches: not how to write yet another “terminal agent that writes code,” but how to build an AI coworker that can run unattended, across apps, ask before it acts, and hand you a finished artifact when it is done—the OpenWorker kind (open-sourced by Andrew Ng’s team, package name coworker). How it teaches: same as this repository’s Build a general-task agent from scratch (Manus lineage)—follow one real scene, start from a blank page, and every time you hit a wall, add one part. You will watch with your own eyes how a “chatbot” grows, step by step, into a “digital coworker.” Source to compare: every part gives OpenWorker’s file:line; you can read and check back under 参考项目/openworker/coworker/ (baseline commit db93d75). Prerequisite: having read either Build an AI coding assistant from scratch or the Manus-lineage tutorial is better, but not required. You only need to know “agent loop = the model speaks → software acts for it → the result is fed back to the model → until it no longer wants a tool.”


Part 2

See the finish line: what a “digital coworker” looks like

Before you write the first line, see clearly what you are assembling. The boundary between a “coding agent” and an “AI coworker” is not the model, not the tools. It is these four things:

  1. It acts outward on your behalf (send Slack, change a calendar, open a PR), so before it acts it must be able to ask you;
  2. You may not be there (it runs in the background / on a schedule), so “ask you” cannot be a modal that dead-waits; it has to be a queue that can stop, can be answered from anywhere, and does not lose itself on restart;
  3. What it attaches to is your apps (GitHub / Slack / Jira / Gmail / calendar…), not a codebase;
  4. It goes to work on its own (runs on the hour, hands you a finished artifact).

Draw those four things into the assembly diagram, and that is what we will assemble step by step:

flowchart TB
    subgraph FRONT["你能看到的(多个界面,共享一条事件流)"]
        GUI["桌面 App / 终端 / Slack 线程"]
    end
    subgraph BRAIN["同事的大脑(一个进程内)"]
        MGR["会话编排 SessionManager"]
        ENG["主循环 TurnEngine(异步)"]
        PERM["权限闸门(五档模式)"]
        INBOX["收件箱(要人时挂在这,重启不丢)"]
        SCHED["调度器(按点自己上班)"]
    end
    subgraph WORLD["你的世界(全用你的密钥)"]
        MODEL["任意 provider 的模型(含本地 Ollama)"]
        TOOLS["文件 / shell / 40 个连接器 / MCP"]
    end
    GUI -->|事件流| MGR --> ENG --> PERM
    ENG -->|要人点头| INBOX --> GUI
    SCHED -->|定时触发| ENG
    ENG -->|统一抽象| MODEL
    ENG -->|裁决后执行| TOOLS

Remember this figure. Every step below fills in one box.


Part 3

Step 0 · The scene enters: a morning briefing

Our coworker only needs to be able to do one thing (do one thing thoroughly first, then talk about general):

“Every morning at 8, take the PRs waiting for my review on GitHub + today’s meetings on the calendar, assemble them into a morning briefing, and send it to my Slack. When you hit an action with consequences—send a message, change a calendar—ask me first.”

Inside that one sentence hide every wall we will hit: - Need to call a model → but I may use OpenAI, or local Ollama (step 1) - Need to read GitHub, read the calendar, send Slack → tools, and some are read-only, some have consequences (steps 2, 5) - Need to go step by step → agent loop (step 3) - At 8 in the morning I am still asleep → unattended + schedule (steps 6, 11) - “Ask me before sending Slack,” but I am asleep → inbox (step 6) - The service restarted in the night → hanging tasks must not be lost (step 7)

Now start filling boxes.


Part 4

Step 1 · Give it a provider-agnostic brain

The plainest start: send the user’s words to the model, print the model’s words.

history = [{"role": "user", "content": "帮我写今天的晨报"}]
reply = openai_client.chat.completions.create(model="gpt-5.6", messages=history)
print(reply.choices[0].message.content)

First wall: the user says “I want to use local Ollama / use Claude / use Gemini.” These three houses’ SDKs, message formats, and capabilities (can it see images, can it read PDFs) are all different. If your history structure is bound to one house, swapping models means a rewrite.

The part you add: canonical history + provider routing + adapt by capability on every call. OpenWorker’s move (and the reason it is built on Andrew Ng’s team’s aisuite):

  • History is always stored in “canonical OpenAI shape.” Each provider, at the moment of the call, converts to its own house format (engine.py:878 _outbound_messages is the only provider-feed exit).
  • Route by provider: prefix (gpt-5.6 goes OpenAI, ollama:llama3 goes local, anthropic:claude-… goes Claude)—ProviderRouter (agent.py:214).
  • Capability adapt sits before send, re-judged against the current model: the attachment is a PDF and the model has no native support → locally extract text / convert pages to images as a downgrade; there is an image and the model has no vision → replace with a placeholder (engine.py:906-961). Because it re-judges every time, swapping models mid-session also always does the right thing (switch_model, engine.py:180).
flowchart LR
    H["规范历史(OpenAI 形状)"] --> OUT["发送前 _outbound_messages"]
    OUT --> STRIP["剥掉仅显示的旁路
source / _display / ts / reasoning / notice"] STRIP --> ADAPT["按当前模型能力适配
PDF→抽文/转图 · 图→占位符"] ADAPT --> ROUTE["ProviderRouter 按 provider: 前缀"] ROUTE --> M1["OpenAI"] ROUTE --> M2["Anthropic 原生"] ROUTE --> M3["Gemini 原生"] ROUTE --> M4["Ollama 本地"]

🧠 One sentence: thoroughly separate “how history is stored” from “whom you send to, and how.” History is canonical shape; adapt happens at send time—“swap models at any time” and “attachments upgrade/downgrade with the model” both become free byproducts.

✅ Acceptance assertion: stuff an image into history, set the model to a no-vision model; in the payload before send the image should become [image attachment — not viewable by this model], while the image is still in the persisted history (engine.py:938-961: only the send copy is changed, self.messages is not touched).


Part 5

Step 2 · Give it hands, and stamp each hand with a risk label

Speaking alone does not make a coworker. It has to be able to read GitHub, read the calendar, write files. Give it tools:

tools = [read_file, list_prs, list_calendar, write_file, run_shell, send_slack]

Wall: these hands’ danger levels are worlds apart—read_file / list_prs are read-only; run_shell / send_slack have consequences. If you treat them the same, either everything needs your nod (unbearable) or everything is allowed (dangerous).

The part you add: every tool carries “risk metadata.” OpenWorker’s tools are all based on aisuite’s ai.tool + ai.ToolMetadata (tools/subagent.py:129-137 is the clearest example):

ai.tool(explore, metadata=ai.ToolMetadata(
    category="search", risk_level="low", requires_approval=False))

Risk splits into four classes (classify in risk.py): low (read-only / search), WRITE_LOCAL (write local files), EXEC (run shell), EXTERNAL (connectors: send Slack, change a calendar). This label is used in two places later: the agent loop uses it to decide “can this be concurrent” (step 3); the permission engine uses it to decide “do I ask you” (step 5).

🧠 One sentence: a tool’s “danger level” is not a after-the-fact judgment by the permission system. It is an attribute the tool itself declares—declare once; the loop and the gate both read it.


Part 6

Step 3 · Make it turn: async agent loop + concurrent / serial split

Now join “speak → act → feed back” into a loop: if the model wants a tool, execute it, append the result into history, ask the model again, until it no longer wants a tool.

Wall one: in one turn the model asked for 5 tools—3 read PRs, 1 read the calendar, 1 write a file. Serial is too slow; all concurrent is dangerous (write and read concurrent will scramble).

The part you add: low-risk concurrent, high-risk serial. OpenWorker’s TurnEngine._handle_tool_calls (engine.py:437) first authorizes this batch of tools one by one (approval is interactive, must be serial), then: read-only / search (risk_level=low and no approval needed) run concurrent with asyncio.gather; write / shell / anything unlabeled runs strictly one by one in call order (_parallel_safe, engine.py:517).

Wall two: the model call is blocking; one run freezes the whole async loop; the UI cannot turn.

The part you add: wrap the blocking call in to_thread + stream back through a “thread + queue” bridge. The provider’s blocking stream runs on a worker thread, stuffs tokens back to the agent loop through a queue; the agent loop races “wait on the queue” against “wait on Stop”—so a stuck stream cannot drag the whole turn (engine.py:_astream:388).

flowchart TD
    A["模型这一回合要了 N 个工具"] --> B["逐个鉴权(串行,审批是交互的)"]
    B --> C{"按风险分流"}
    C -->|"低危:读 PR / 读日历"| D["asyncio.gather 并发跑"]
    C -->|"高危:写文件 / 发 Slack"| E["按调用序一个个跑"]
    D --> F["结果追加进历史"]
    E --> F
    F --> G{"模型还要工具?"}
    G -->|要| A
    G -->|不要| H["晨报出来了 → 回合结束"]

✅ Acceptance assertion: let the model request 3 read-only tools + 1 write tool in one go; in the log the 3 read-only should started at almost the same time, and the write one started alone after them (engine.py:485-502).


Part 7

Step 4 · Fit the brakes: stoppable from any state, and no orphans

Wall: the morning briefing has been pulling for a long time; you want to shout stop. But “stop” can happen at any moment—currently streaming tokens, currently running a shell command, currently waiting for your approval, between two iterations. And the more hidden pit: if the model already emitted 3 tool_calls and you shout stop at the 2nd, the remaining tool_call has no corresponding result—hosted chat templates will reject this kind of “orphan tool_call,” and the next durable resume will bounce it again.

The part you add: an interrupt that can stop from any state, and that fills an error result for every tool still waiting to run. OpenWorker’s request_interrupt (engine.py:120): set an asyncio.Event; the producer thread in the stream drops the stream between two chunks; the interrupt hook kills the running shell; the approval await resolves as “interrupted”; the between-iteration checkpoint exits—and every still-unanswered tool_call is filled with a tool error result of interrupted by user (_interrupted_tool, engine.py:504); history never has an orphan.

🧠 One sentence: interrupt is not as simple as “break the loop.” An agent that can run unattended must guarantee that after “stop,” history is clean and resumable—no half tool_call. This discipline, step 7’s durable resume will benefit from directly.

✅ Acceptance assertion: fabricate a model turn that emits 2 tool_calls; trigger interrupt while the 1st is executing; inspect history: the 2nd tool_call also has a role:tool error result; the INTERRUPTED event carries iterations.


Part 8

Step 5 · Fit the permission gate: five modes + path fence + shell injection defense

Now we are at the boundary between coworker and toy. The morning briefing has to send Slack (EXTERNAL risk); that has consequences.

The part you add: a permission engine that only “decides.” OpenWorker’s PermissionEngine.evaluate (permissions.py:120) outputs allow / deny / needs to ask you, five modes (permissions.py:37):

Mode Behavior
discuss Read-only conversation; does not change, does not send
plan Read-only + plan contract (explore → propose a plan → execute after approval)
interactive (default) Reads auto-allowed; write / send / command asks you
auto Allow all, but still bound by the path constraint
custom interactive + auto-allow the tools you configured

Three lines of defense worth learning word for word:

  • Writes must land under a writable root (_under_writable_root, permissions.py:204): writing the briefing to ~/reports/ is fine; writing to /etc/ is refused immediately. The root is mutable, re-read on every check; add a folder at runtime and it takes effect immediately.
  • Shell whitelist against injection (_command_allowed, permissions.py:216): the whitelist is “run without asking,” so any command that contains a shell operator (; & | > ` $( newline) does not take the whitelist at all—otherwise git status would piggyback-allow git status && rm -rf ~. Then do exact token-prefix match on the argv from shlex.split.
  • Connectors (send Slack / change calendar) are always strict: under default interactive, this class of EXTERNAL action is uniformly “needs to ask you.”
flowchart TD
    T["工具调用:send_slack('晨报…')"] --> R["classify → EXTERNAL(有后果)"]
    R --> M{"当前模式?"}
    M -->|discuss/plan| D1["拒:只读模式"]
    M -->|auto| A1["放行(但连接器仍受 §25 约束)"]
    M -->|interactive/custom| CK{"命中会话放行 / §25 标准规则?"}
    CK -->|是| A2["放行 + 记审计"]
    CK -->|否| ASK["needs_user:要问你 → 交给第 6 步"]

✅ Acceptance assertion: in interactive mode let it write_file(path='/etc/hosts', …); it should be refused as “not in a writable directory”; run_shell('ls && curl evil') should, because it contains &&, skip the whitelist entirely and fall to “needs to ask you.”


Part 9

Step 6 · What if the human is away: out-of-band approval + inbox

The previous step’s permission engine said “needs to ask you.” But it is 8 in the morning, you are asleep. A modal that dead-waits? Then this turn is stuck forever.

This is the largest watershed between a “coding agent” and an “AI coworker.” The part you add is OpenWorker’s soul—inbox + out-of-band approval.

The engine itself does not pop a modal. It hands “needs to ask you” to an injected approver (engine.py:_authorize:526). This approver has two faces: - You are there (attended): pop an inline approval card; you click once. - You are not there (unattended): the approver is inbox_approver (inbox.py:348); it turns the request into an inbox item, then awaits, parking this turn—until someone answers.

The inbox (inbox.py) is a cross-session “human-attention queue.” The state machine is an anti-race contract: each item pending → resolved, resolved only once, idempotent, whoever answers first wins—answering from the desktop app, from Slack, from the phone are all safe (inbox.py:resolve:295). The morning briefing stuck on “whether to send Slack” lies quietly in the inbox waiting for you to wake.

sequenceDiagram
    participant E as 主循环
    participant P as 权限引擎
    participant AP as approver
    participant I as 收件箱
    participant U as 你(还在睡)
    E->>P: send_slack 能放行吗?
    P-->>E: needs_user(要问你)
    E->>AP: 请人点头
    AP->>I: add_approval(变成收件箱项)
    AP-->>E: await 挂起这个回合
    Note over E,I: 回合就地睡着,不占 CPU,不卡别的任务
    U->>I: 醒来,在任意界面点"允许"
    I-->>AP: resolve → 唤醒
    AP-->>E: ONCE(放行)
    E->>E: 真的发 Slack,回合继续

🧠 One sentence: changing “approval” from “a modal that dead-waits” into “put a card on a durable queue and park the turn” is the premise of an agent that can run unattended. Human present, answer inline; human away, go into the inbox—the same record, only visibility differs.

✅ Acceptance assertion: set the session to unattended, trigger one send_slack; check that the inbox has a pending approval item, the turn is parked (no error, no continuation); from another “surface” call resolve(item_id, "allow"); the turn should wake and continue.


Part 10

Step 7 · Restart without loss: durable resume

Wall: the morning briefing is hanging in the inbox waiting for you to wake. But in the night the server updated and restarted. The turn was in memory—gone. You click “allow” in the morning; nothing happens.

The part you add: durable resume. Two designs together make it hold:

  1. Inbox items are durable and idempotent—the key is (session_id, tool_call_id) (inbox.py:133).
  2. A turn can rebuild a park from “tool_calls in the last assistant message that have not yet been answered” (engine.py:resume:250 + _unanswered_trailing_tool_calls:268).

After restart, resume re-handles that “waiting to be answered” tool_call—the approval callback looks in the inbox, this item already exists (idempotent), so it reuses it, does not re-pop; already-answered calls are skipped, so nothing double-runs. The “allow” you clicked in the morning still takes effect; the briefing still sends.

flowchart LR
    S1["回合挂在收件箱等审批"] --> CRASH["服务重启(内存清空)"]
    CRASH --> R["resume():从历史找'还没答的 tool_call'"]
    R --> IDEM["审批回调查收件箱:这个 tool_call_id 已有项"]
    IDEM --> REUSE["复用已存在的项(幂等,不重弹)"]
    REUSE --> DONE["你之前/之后点的'允许'照样生效,不双跑"]

🧠 One sentence: step 4’s discipline “interrupt leaves no orphans” is cashed here as “restart can continue.” Clean history + idempotent inbox items = a turn can die and come back. This is the hardest bone that distinguishes a “digital coworker” from a “chatbot.”

✅ Acceptance assertion: manufacture a parked turn → drop the engine object (simulate restart) → with the same durable history + inbox, new up an engine and resume() → the turn continues and that tool_call executes only once.


Part 11

Step 8 · Give it memory: explicit SQLite facts

Wall: last week you told it “in the morning briefing, PRs only look at the backend/ directory.” This week it forgot again.

The part you add: cross-session memory. Notice OpenWorker’s trade-off here is sharp—it does not do automatic context compaction (unlike some agents that summarize old history and throw it away). Instead memory is explicit, durable SQLite facts: SQLiteMemoryStore (memory/sqlite_store.py), two scopes GLOBAL / WORKSPACE, tools remember / memory_update / memory_forget.

The key is that paragraph of guidance on “when to remember” (agent.py:62); without it the model either does not remember, or remembers a pile of noise: only remember the user’s corrections and preferences (with reasons), and project background that cannot be inferred; do not remember code / git history / what is already in convention files; use absolute dates; before storing, look up known memories to dedupe.

🧠 One sentence: OpenWorker would rather let a human manage memory explicitly (remember) than let the model automatically drop context—for a “coworker that delivers finished artifacts,” predictable is more important than saving tokens.


Part 12

Step 9 · Give it identity: persona and family

Wall: the morning-briefing job needs connectors + a schedule; but if you want the same engine to “edit code,” what it needs is file / git / explorer sub-agents; the two tool sets are completely different.

The part you add: persona (persona is the agent) + family packaging. In OpenWorker persona is the agent (agents/registry.py delegates to personas/registry.py). One Agent (agents/base.py:28) = system prompt + tool factory + family (code / knowledge) + messaging / connectors flags. At assembly (agent.py:build_engine:109) different hands are issued by family:

  • knowledge family (the morning-briefing coworker is this): scheduled dispatch, self-wake, multi-directory, channel subscriptions, send_message / send_file.
  • code family: a read-only explorer sub-agent (outsource broad research to a brand-new context, force PLAN read-only, a sub TurnEngine with no approval—tools/subagent.py).

Built-in personas: cowork (default, produce deliverables), code, chat; third-party personas can be installed from a directory / git, but disabled by default, enabled only after you authorize the declared capabilities (personas/registry.py:340)—a persona marketplace with a consent gate.

🧠 One sentence: the same engine, swap one persona (system prompt + tool factory + family), and it goes from “morning-briefing coworker” to “code coworker.” Identity is not a hardcoded branch; it is an installable, authorizable configuration.


Part 13

Step 10 · Connect the world: connectors + Slack @mention is a session

The morning briefing has to read GitHub, read the calendar, send Slack. These are connectors (connectors/, 25+ houses: GitHub / Slack / Jira / Notion / Gmail / Google Calendar / Telegram…). Tokens live in a local secret store; the cloud only brokers the OAuth handshake.

Wall + the punch line: you want to @ it in Slack and just use it. When @OpenWorker is @mentioned in a channel that does not yet have a session, the router spawns a session that “owns this thread” and replies into it (mentions.py, §31). The cleverness is that the thread_target string ("slack:C0123:1700….000100") does three jobs in one string: ① the lookup key thread→session; ② the delivery target of send_message; ③ the target of §25 standard authorization rules—every time the engine is rebuilt, permissions.task_rules is re-derived from it, so the pre-authorization “reply in this thread without asking” still holds after restart.

flowchart TD
    AT["Slack 频道 @OpenWorker"] --> NEW{"该线程已有会话?"}
    NEW -->|有| ROUTE["路由到已有会话"]
    NEW -->|没有| SPAWN["生成'拥有此线程'的会话"]
    SPAWN --> TT["thread_target 一串三用"]
    TT --> U1["① 查找键"]
    TT --> U2["② send_message 投递目标"]
    TT --> U3["③ §25 标准授权 target(重启存活)"]

This is how step 5’s permissions land: it can reply in this one thread without asking (because you @mentioning it is the authorization), but sending elsewhere, or running a shell, still has to ask.


Part 14

Step 11 · Let it go to work on its own: scheduled automation + self-wake

The last box: at 8 in the morning nobody shouts at it; it has to wake itself.

The part you add: a scheduler. OpenWorker’s Scheduler (automation/scheduler.py:23) runs in the resident service. The policy is tasks missed during downtime are caught up once at boot + overlapping is skipped (if the previous run has not finished, do not stack), and every due task is only spawned, not awaited—because a run may hang on an inbox approval (like the morning briefing waiting for you to click “send Slack”), and one stuck automation must never block the whole scheduling ring and other tasks. Next trigger time is computed with croniter.

Then add self-wake (selfwake.py): a session can “I sleep first; at a time / when something finishes / when some event arrives, wake me to continue”—each scheduler tick incidentally resumes due wakes.

flowchart LR
    T["每 30s tick(首次=补跑漏的)"] --> DUE["到期任务:晨报 8:00"]
    DUE --> SPAWN["只 spawn 不 await(重叠跳过)"]
    SPAWN --> RUN["跑晨报会话(第 1–10 步全用上)"]
    RUN --> WAIT{"要发 Slack,你还在睡?"}
    WAIT -->|是| PARK["挂进收件箱,不堵调度环"]
    WAIT -->|否/已授权| DELIVER["产出晨报 + 逐字记录落应用"]
    T --> WAKE["顺便恢复到期 self-wake"]

At this point every box on the assembly diagram is filled.


Part 15

🎬 Full replay: how the morning-briefing task runs from start to finish

String the eleven steps and watch how that sentence “every morning send me the briefing on Slack” actually finishes:

  1. 8:00 scheduler tick (step 11) → finds the morning-briefing task due → spawns a knowledge-family cowork session (step 9).
  2. Session assembly (step 9): provider routing binds your model (step 1), installs GitHub / calendar / Slack connectors + risk labels (steps 2, 10), permissions set interactive (step 5), session set unattended.
  3. The agent loop turns (step 3): the model says “I will read PRs and the calendar first” → these two are low-risk → concurrent fetch (steps 2, 3).
  4. The model kneads PRs + meetings into a briefing draft, says “I want send_slack.”
  5. Permission gate (step 5): send_slack is EXTERNAL, interactive mode → needs_user.
  6. You are asleep → the approver is inbox_approver (step 6) → the briefing becomes an approval card lying in the inbox, the turn parks in place.
  7. Night service restart (step 7) → at boot catch-up / resume, resume rebuilds the park from the “waiting send_slack”; that card is still in the inbox (idempotent).
  8. You wake at 9, click “allow” on the phone → inbox resolve → wake the turn → actually send the briefing to Slack.
  9. The model no longer wants a tool → the turn ends → this run’s full verbatim record lands in the app (step 11).
sequenceDiagram
    participant SC as 调度器
    participant EN as 会话/主循环
    participant PM as 权限
    participant IB as 收件箱
    participant YOU as 你
    SC->>EN: 8:00 到期,spawn 晨报会话
    EN->>EN: 并发读 PR + 日历(低危)
    EN->>PM: send_slack 放行吗?
    PM-->>EN: needs_user
    EN->>IB: 变成审批卡(你在睡)
    Note over EN,IB: 回合挂起(半夜重启也能 resume)
    YOU->>IB: 9:00 手机点"允许"
    IB-->>EN: 唤醒
    EN->>YOU: 晨报发到 Slack ✅

Walk this trip and you understand: an “AI coworker” is not “a stronger chatbot.” It is “an execution system that can park a turn safely, wait for you, and not fear restart.”


Part 16

Ship it as a product: desktop shell + service sidecar + local secrets

The engine is there; last, wrap it into something the user can double-click. OpenWorker’s product shape (README.md + server/run.py + surfaces/):

  • A Tauri (Rust) desktop shell supervises a Python service sidecar (openworker-server, FastAPI + uvicorn). When the shell dies the service suicides (COWORKER_EXIT_WITH_PARENT; you also have to handle the PID pit that under PyInstaller packaging the service is a grandchild process, server/run.py).
  • UI / terminal / Slack are all consumers of the same event stream—so “visible on the desktop, answerable on Slack, still running unattended” is the same session.
  • Local-first: model keys and connector tokens all in the local secret store (secrets.py); usable without login (fill credentials by hand). Each start generates a one-time token for auth.
  • Also a Rust STT sidecar for voice input, auto-update.

You do not have to use Tauri—a FastAPI service + a web UI (consuming the event stream) is enough to run all eleven steps. The desktop shell is only the sugar coating of the “local-first” experience.


Part 17

Appendix

Appendix 1 · Machine-check list before you ship (acceptance assertions collected)

Collect the assertions scattered in the steps into one “is it done” list:

  1. Under a no-vision model, the image in the send payload becomes a placeholder, but the image is still in persisted history (step 1).
  2. 3 read-only + 1 write: read-only concurrent, write runs alone after them (step 3).
  3. A turn emits 2 tool_calls; interrupt at the 1st; the 2nd is also filled with an error result; no orphan (step 4).
  4. Under interactive, writing /etc/ is refused; ls && curl does not take the whitelist because it contains && (step 5).
  5. Under unattended, send_slack produces a pending approval item, the turn parks; after resolve from another surface the turn continues (step 6).
  6. After dropping the engine of a parked turn, resume() continues, the tool_call executes only once (step 7).
  7. A second add of an inbox item with the same (session_id, tool_call_id) returns the same item (idempotent, steps 6/7).
  8. Scheduler: one automation stuck on approval does not block other due tasks (step 11).

Appendix 2 · Ten ways it most easily blows up

  1. History bound to one provider’s format → swapping models / adding local Ollama is a rewrite. Store canonical shape, convert at send (step 1).
  2. Capability adapt written into persisted history → after swapping models, old attachments’ downgrade state is scrambled. Adapt only changes the send copy (step 1).
  3. One approval policy for all tools → either unbearable or dangerous. Let tools carry their own risk labels (step 2).
  4. Write tools also concurrent → races. Only concurrent low-risk, and no-approval-needed (step 3).
  5. Interrupt only breaks the loop → leaves orphan tool_calls; hosted templates reject, resume re-pops. Fill an error result for every still-waiting tool (step 4).
  6. Shell whitelist uses prefix match → git status && rm -rf ~ is allowed. Containing an operator is refuse + exact argv prefix (step 5).
  7. Approval only knows how to dead-wait on a modal → unattended freezes immediately. Change to inbox park (step 6).
  8. Inbox items not idempotent → restart resume re-pops, actions double-run. Key with (session_id, tool_call_id) (steps 6/7).
  9. Scheduler awaits every task → one automation stuck on approval blocks everything. Only spawn, do not await (step 11).
  10. Third-party persona enabled as soon as installed → unauthorized capabilities take effect immediately. Disabled by default, wait for consent (step 9).

Appendix 3 · Source index (check back under 参考项目/openworker/coworker/)

Step Part Source
1 Provider-agnostic brain / capability adapt engine.py:878 (_outbound_messages), agent.py:214 (Router), engine.py:180 (swap model)
2 Tools + risk metadata tools/subagent.py:129 (ai.tool / ToolMetadata), risk.py (classify)
3 Async agent loop + concurrent / serial engine.py:294 (_loop), :437 (_handle_tool_calls), :517 (_parallel_safe), :388 (_astream)
4 Interruptible from any state, no orphans engine.py:120 (request_interrupt), :504 (_interrupted_tool)
5 Permission gate permissions.py:120 (evaluate), :204 (writable root), :216 (shell injection defense), :37 (five modes)
6 Out-of-band approval + inbox engine.py:526 (_authorize), inbox.py:295 (state machine), :348 (inbox_approver), :133 (idempotent)
7 Durable resume engine.py:250 (resume), :268 (unanswered tool_call), inbox.py:133
8 SQLite memory memory/sqlite_store.py, agent.py:62 (when-to-remember guidance)
9 Persona / family agents/base.py:28, agent.py:109 (assembly), personas/registry.py:340 (consent gate)
10 Connectors + @mention connectors/, mentions.py (thread_target three uses in one string)
11 Scheduler + self-wake automation/scheduler.py:23, selfwake.py
Product Desktop shell + sidecar server/run.py, secrets.py, surfaces/

Part 18

Close

What you now have is not “yet another agent loop.” It is an execution system that can park a turn safely, wait for a human, survive restart, go to work on the hour, ask before it acts, and never over-authorize a shell. Attach it to your Slack and calendar, and it is a digital coworker; swap the persona to the code family, and it can edit code again.

The core is not how strong the model is. It is the ring of system around the model—inbox, durable resume, out-of-band approval, §25 tiers—that turns “a model that can call tools” into “a coworker you dare let run unattended.” That is what OpenWorker wants to prove, and what this tutorial wants to put in your hands.

For a full source dissection, see 项目分析/openworker-源码分析.md; for a horizontal matchup against eight other coding agents, see 项目分析/横向对比与总结评价.md.

↑