- Migrate src/ → plugin/ (plugin/core/, plugin/web/, plugin/commands/)
and src/mcp/ → services/ per OpenClaw plugin dev spec
- Add Gemini CLI backend (plugin/core/gemini/sdk-adapter.ts) with GEMINI.md
system-prompt injection
- Inject bootstrap as stateless system prompt on every turn instead of
first turn only: Claude via --system-prompt, Gemini via workspace/GEMINI.md;
eliminates isFirstTurn branch, keeps skills in sync with OpenClaw snapshots
- Fix session-map-store defensive parsing (sessions ?? []) to handle bare {}
reset files without crashing on .find()
- Add docs/TEST_FLOW.md with E2E test scenarios and expected outcomes
- Add docs/claude/BRIDGE_MODEL_FINDINGS.md with contractor-probe results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.9 KiB
Bridge Model Probe Findings
Purpose
Document actual test results from running the contractor-probe test plugin against a live
OpenClaw gateway. Resolves the two critical unknowns identified in earlier feasibility review.
Test setup: installed contractor-probe plugin that exposes an OpenAI-compatible HTTP server
on port 8799 and logs every raw request body to /tmp/contractor-probe-requests.jsonl.
Created a probe-test agent with model: contractor-probe/contractor-probe-bridge.
Sent two consecutive messages via openclaw agent --channel qa-channel.
Finding 1 — Custom Model Registration Mechanism
There is no plugin SDK registerModelProvider call.
The actual mechanism used by dirigent (and confirmed working for contractor-probe) is:
Step 1 — Add provider to openclaw.json
Under models.providers, add an entry pointing to a local OpenAI-compatible HTTP server:
"contractor-probe": {
"baseUrl": "http://127.0.0.1:8799/v1",
"apiKey": "probe-local",
"api": "openai-completions",
"models": [{
"id": "contractor-probe-bridge",
"name": "Contractor Probe Bridge (test)",
"reasoning": false,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 4096
}]
}
Step 2 — Plugin starts a sidecar HTTP server
The plugin's register() function starts a Node.js HTTP server on gateway_start (protected
by a globalThis flag to prevent double-start on hot-reload). The server implements:
GET /v1/models— model listPOST /v1/chat/completions— model inference (must support streaming)POST /v1/responses— responses API variant (optional)
Step 3 — Agent uses provider/model as primary model
{
"id": "my-agent",
"model": { "primary": "contractor-probe/contractor-probe-bridge" }
}
What this means for ContractorAgent
The contractor-claude-bridge model should be registered the same way:
- Install script writes a provider entry to
openclaw.jsonpointing to the bridge sidecar port - Plugin starts the bridge sidecar on
gateway_start openclaw contractor-agents addsets the agent's primary model tocontractor-claude-bridge
No plugin SDK model registration API exists or is needed.
Finding 2 — Exact Payload Sent to Custom Model
OpenClaw sends a standard OpenAI Chat Completions request to the sidecar on every turn.
Endpoint and transport
POST /v1/chat/completions
Content-Type: application/json
stream: true ← streaming is always requested; sidecar MUST emit SSE
Message array structure
Turn 1 (2 messages):
| index | role | content |
|---|---|---|
| 0 | system |
Full OpenClaw agent context (~28,000 chars) — rebuilt every turn |
| 1 | user |
[Sat 2026-04-11 08:32 GMT+1] hello from probe test |
Turn 2 (3 messages):
| index | role | content |
|---|---|---|
| 0 | system |
Same full context (~28,000 chars) |
| 1 | user |
[Sat 2026-04-11 08:32 GMT+1] hello from probe test |
| 2 | user |
[Sat 2026-04-11 08:34 GMT+1] and this is the second message |
Note: the probe sidecar did not emit proper SSE. As a result, turn 2 shows no assistant message between the two user messages. Once the bridge sidecar returns well-formed SSE, OpenClaw should include assistant turns in history. Needs follow-up verification with streaming.
System prompt contents
The system prompt is assembled by OpenClaw from:
- OpenClaw base instructions (tool call style, scheduling rules, ACP guidance)
- Workspace context files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, BOOTSTRAP.md)
- Skill definitions
- Tool guidance text
In the test, total system prompt was 28,942 chars. This is rebuilt from scratch on every turn.
User message format
[Day YYYY-MM-DD HH:MM TZ] <message text>
Example: [Sat 2026-04-11 08:32 GMT+1] hello from probe test
Tool definitions
Full OpenAI function definitions are sent on every request as tools: [...]. In the test run,
37 tools were included (read, edit, exec, cron, message, sessions_spawn, dirigent tools, etc.).
Other request fields
| field | observed value | notes |
|---|---|---|
model |
contractor-probe-bridge |
model id as configured |
stream |
true |
always; bridge must stream |
store |
false |
|
max_completion_tokens |
4096 |
from provider model config |
What this means for ContractorAgent
The input filter is critical. On every turn, OpenClaw sends:
- A large system prompt (28K+ chars) that repeats unchanged
- The full accumulated user message history
- No (or incomplete) assistant message history
The bridge model must NOT forward this verbatim to Claude Code. Instead:
- Extract only the latest user message from the messages array (last
userrole entry) - Strip the OpenClaw system prompt entirely — Claude Code maintains its own live context
- On first turn: inject a one-time bootstrap block telling Claude it is operating as an OpenClaw contractor agent, with workspace path and session key
- On subsequent turns: forward only the latest user message text
This keeps Claude as the owner of its own conversational context and avoids dual-context drift.
The bridge sidecar must support SSE streaming. OpenClaw always sets stream: true. A
non-streaming response causes assistant turn data to be dropped from OpenClaw's session history.
Finding 3 — Claude Code Session Continuation Identifier
From Claude Code documentation research:
Session ID format
UUIDs assigned at session creation. Example: bc1a7617-0651-443d-a8f1-efeb2957b8c2
Session storage
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
<encoded-cwd> is the absolute working directory path with every non-alphanumeric character
replaced by -:
/home/user/project→-home-user-project
CLI resumption
# Non-interactive mode with session resume
claude -p --resume <session-uuid> "next user message"
-p is print/non-interactive mode. The session UUID is passed as the resume argument.
The output includes the session ID so the caller can capture it for next turn.
SDK resumption (TypeScript)
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "next user message",
options: {
resume: sessionId, // UUID from previous turn
allowedTools: ["Read", "Edit", "Glob"],
}
})) {
if (message.type === "result") {
const nextSessionId = message.session_id; // capture for next turn
}
}
Session enumeration
import { listSessions, getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
const sessions = await listSessions(); // keyed by session UUID
const info = await getSessionInfo(sessionId);
What this means for ContractorAgent
The SessionMapEntry.claudeSessionId field should store the UUID returned by message.session_id
after each Claude turn. On the next turn, pass it as options.resume.
The session file path can be reconstructed from the session ID and workspace path if needed for recovery or inspection, but direct SDK resumption is the primary path.
Finding 4 — Sidecar Port Conflict and Globalthis Guard
During testing, the probe sidecar failed to start with EADDRINUSE when openclaw agent --local
was used alongside a running gateway, because both tried to spawn the server process.
This is exactly the hot-reload / double-start problem documented in LESSONS_LEARNED items 1, 3, and 7. The fix for the bridge sidecar:
- Check a lock file (e.g.
/tmp/contractor-bridge-sidecar.lock) before starting - If the lock file exists and the PID is alive, skip start
- Protect the
startSidecarcall with aglobalThisflag - Clean up the lock file on
gateway_stop
Open Questions Resolved
| Question | Status | Answer |
|---|---|---|
| Custom model registration API? | ✅ Resolved | openclaw.json provider config + OpenAI-compatible sidecar |
| Claude session continuation identifier? | ✅ Resolved | UUID via message.session_id, resume via options.resume |
| Does OpenClaw include assistant history? | ⚠️ Partial | Appears yes when sidecar streams correctly; needs retest with SSE |
| Streaming required? | ✅ Resolved | Yes, stream: true always sent; non-streaming drops assistant history |
Immediate Next Steps (Updated)
- Add SSE streaming to probe sidecar — retest to confirm assistant messages appear in turn 3
- Build the real bridge sidecar — implement SSE passthrough from Claude SDK output
- Implement input filter — extract latest user message, strip system prompt
- Implement session map store — persist UUID → OpenClaw session key mapping
- Implement bootstrap injection — first-turn only; include workspace path and session key
- Add lock file to sidecar — prevent double-start (LESSONS_LEARNED lesson 7)