ACP Inbox Bridge — goudan (OpenClaw) perspective
You are running inside an OpenClaw main session (goudan). The other peer
is mavis (MiniMax Code, dispatched as a child session). You can
communicate via the ACP inbox — not just return a final answer.
This is the goudan-side companion to the acp-collab Skill. acp-collab
teaches mavis how to talk to you; this Skill teaches you how to talk to
mavis. Both Skills drive the same client/_acp_client.inbox_* HTTP
transport, so the loopback-only, no-redirect, token-via-env security
model is shared.
When to activate this Skill
Activate this Skill whenever any of the following is true:
- You are running a long task and mavis should see progress
- You found an issue (cron failure, audit anomaly, bridge timeout) that mavis needs to act on
- You need a piece of mavis-side state (e.g. a file under
~/.minimax/, a memory note, the OAuth token expiry) that you cannot read from your own domain - mavis has asked you a question via the inbox and you need to unblock
them with a
msg_type=answer - A decision needs human judgment and mavis is closer to the human than you are
Do NOT activate this Skill to bypass the domain boundary: do not use it
to read ~/.minimax/ directly, to read mavis-side logs, or to probe
mavis-side processes. The inbox is for messaging, not for direct
file or process access on the mavis side.
Setup
The bundled client at <plugin_root>/client/_acp_client.py is the same
one the mavis-side Skills use. There is no ACP_HOME to set, no
external Python SDK to install, and no sys.path to mutate. The
goudan-side wrapper scripts/acp_inbox.py resolves the plugin root
through the host-provided $PLUGIN_ROOT (mcode 0.2.4+ sets this
automatically when the Skill is loaded). The bundled scripts
themselves also read $PLUGIN_ROOT if you prefer to set it
explicitly, and fall back to __file__ only when the snippet is
written into a .py file inside the Plugin tree.
Note: this Plugin does not inject a custom env var (an earlier draft used
$ACP_PLUGIN_ROOT, but no runtime actually sets it). Always rely on the portable host variable$PLUGIN_ROOTwhen invoking the Skill from a non-Plugin context, or setPLUGIN_ROOT=/path/to/openclaw-acp-bridgebefore running the snippet.__file__is honored as a last-resort fallback forpython some_file.pyinvocations and is not available inpython -c "..."orpython <stdin>contexts.
Authentication
The bundled client reads the bearer token from one of (first hit wins):
$ACP_TOKEN(recommended for shells and CI)~/.acp_token(one line, no trailing newline)<plugin_root>/.acp_token(one line; co-located fallback)
The client attaches Authorization: Bearer <token> to every request to
http://127.0.0.1:9999/acp/*. Do not read, print, or pass the token
yourself. The client also refuses to follow HTTP redirects and refuses
to talk to anything other than the literal loopback allow-list
({127.0.0.1, ::1, [::1]}). The round-5 amendment removed localhost
from the allow-list to avoid DNS / hostname resolution attacks.
If the token cannot be located, the client raises ACPTokenMissing.
Tell the user to set $ACP_TOKEN (or write one of the fallback files)
and stop; do not retry.
Calling the goudan-side wrapper
The Skill is loaded by the Plugin runtime, which sets $PLUGIN_ROOT
to the directory that contains this Plugin's client/ and scripts/.
The snippet below reads $PLUGIN_ROOT directly, falling back to
__file__ only when the caller has saved the snippet into a .py
file inside the Plugin tree.
import os
import sys
# `PLUGIN_ROOT` is set by the Plugin runtime (mcode 0.2.4+) and points
# at the directory that contains this Plugin's `client/` and `scripts/`.
# The `__file__` fallback is only valid when this snippet lives inside
# a .py file; `python -c "..."` and `python <stdin>` callers MUST set
# `PLUGIN_ROOT` themselves.
_plugin_root = os.environ.get("PLUGIN_ROOT") or os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
sys.path.insert(0, os.path.join(_plugin_root, "scripts"))
from acp_inbox import ACPInbox
acp = ACPInbox()
Protocol
1. First message of the session (goudan announces itself)
Use acp.greet(session_id, note=...) to write a peer_greet-shaped
message. The default sender is "goudan" — never pass
sender="mavis" from this side.
acp.greet(session_id, note="starting audit cross-check for 8/31")
2. Push progress (during work)
acp.write(session_id, "[goudan] Step 3 of 7 done. Found 3 candidate cron failures.")
3. Ask a blocking question (when mavis is the only one who can answer)
Use acp.ask(...) to write a question and block for the answer. The
server's /acp/inbox/ask endpoint handles the blocking poll; the
wrapper delegates. Default timeout is 30s; the mavis-side Skill's
acp-collab enforces timeout <= 300, so do not exceed that.
result = acp.ask(
session_id,
"Cron 'Wiki auto-整理' is failing on `list files in memory/`. Should I retry, disable, or have mavis patch the tool?",
timeout=120,
)
if "error" in result:
# timeout / no answer within the budget
raise RuntimeError(f"mavis did not answer within 120s (qid={result['question_id']})")
answer = result["answer"]
4. Answer mavis's question (when asked)
If acp.read(...) shows a message with msg_type == "question" from
mavis, answer it before continuing. acp.read returns a list of
message dicts, each with an id (int) that you pass to acp.answer:
for q in acp.read(session_id, sender="mavis", msg_type="question"):
# q["id"] is the question's message id
acp.answer(q["id"], "Yes, retry the cron; the failure is a non-fatal tool error.")
5. Final report (end of session)
acp.write(
session_id,
"[goudan] DONE. 5 anomalies, 2 retried, 3 escalated to mavis.",
)
Constraints
- Asking is cheaper than redoing. When uncertain, ask. Do not invent cron fix paths, audit-id interpretations, or bridge config decisions.
- One question per
acp.ask. Multi-part questions get only the first answer; split them. - Never write with
sender="mavis". You are goudan. mavis is the mavis-side Skill's job. - Use
timeout <= 300. If longer is needed, push progress first, then ask. - Always send a final report so mavis knows you finished.
- Do not echo raw JSON or large tool outputs in messages; parse and summarize.
RAW mode (LLM rate-limit bypass)
If you (or your LLM) is hitting a 5h Token Plan rate limit, you can
post a message whose content starts with RAW: and the
goudan_inbox_responder.py daemon will execute it as a raw
openclaw <subcommand> --json call without going through the LLM. This
is useful for fast data fetches when the LLM is throttled.
RAW: sessions --active 1440 --limit 30
RAW: cron list
RAW: audit --after 1788105600000 --before 1788192000000 --limit 50
The daemon enforces a 60-second subprocess timeout. The output is
written to the inbox as a message (not an answer).
Failure handling
- If the ACP server is unreachable, fall back to your final-answer channel and note that peer communication was skipped. Do not silently retry in a loop.
- If
acp.asktimes out, push progress (acp.writewithmsg_type="progress") and decide whether to escalate, retry, or skip the question. - If the token is missing, raise
ACPTokenMissingupstream and tell the user to set$ACP_TOKEN(or write one of the fallback files).
Differs from acp-collab
acp-collab |
acp-inbox-bridge (this) |
|
|---|---|---|
| Audience | mavis (MiniMax Code) | goudan (OpenClaw main) |
| Default sender | mavis |
goudan |
| Client API | inbox_write / inbox_read / inbox_ask / inbox_answer / inbox_sessions directly |
ACPInbox class wrapping the same functions |
| Skill file | skills/acp-collab/SKILL.md |
skills/acp-inbox-bridge/SKILL.md (this file) |
| Python wrapper | None (Skills call the client directly) | scripts/acp_inbox.py |
| Smoke test | scripts/smoke.py (mavis-side) |
scripts/test_inbox_goudan.py (goudan-side) |
| Security model | Loopback-only, no-redirect, token-via-env, no hardcoded path. All of these are shared via client/_acp_client.py. |
Same. |
Both Skills drive the same client/_acp_client.py module. The wrapper
in scripts/acp_inbox.py is a thin class API for callers that prefer
OO over function calls; it does not reimplement HTTP.