# sol-web: an agentic harness driven by the ChatGPT web session

audience: AI coding agents first.

Give the existing agentic harness (Claude Code) a brain that costs nothing: the
owner's logged-in ChatGPT chat session, driven through the `gptbridge` browser
automation that already ships `ask-gpt`.

## Why this shape

`ask-gpt` is one-shot: prompt in, text/images out. It has no agency — it cannot
read a file, run a test, or write a patch. Every agentic CLI we own already has
that loop, and every one of them can be pointed at an OpenAI-compatible endpoint.
So the work is NOT to write an agent; it is to make the browser conversation
*look like* an OpenAI chat-completions provider.

```
sw (wrapper contract CLI)
  └─> ccr :3456          provider "solweb", model "sol-web-<effort>"
       └─> claude        Claude Code's tool loop, editing, permissions
            └─> solwebd :8791   /v1/chat/completions  (this spec's work)
                 └─> one live Marionette session on the owner's ChatGPT profile
```

Only `solwebd` and `sw` are new. Claude Code, ccr, and the browser driver are
reused unchanged.

## Measured facts this design rests on

Probed on 2026-08-08 against the live session; these are measurements, not
estimates.

| fact | measurement | consequence |
| --- | --- | --- |
| Multi-turn continuity in one live session | 3 turns, one conversation URL, correct replies | the shim can hold a conversation open per client session |
| Warm turn latency | 10.4s / 14.2s / 15.0s | ~10–20s per tool step; a 30-step task is 5–10 min |
| Cold session startup | 27.1s | paid once per daemon lifetime, not per turn |
| 30KB composer paste | stays text, 0 attachments, 1.2s | history/tool output survives the composer |
| 82KB composer paste | stays text, 0 attachments, 1.4s | the composer accepts it — typing only, send not exercised |
| 145KB turn-1 send | accepted and answered in 16.0s | a full tool-schema preamble really sends; not an inference |
| Tool-protocol adherence, ~600-char prompt | 2/2 turns, exactly one JSON block, valid | synthesised tool calls are viable |
| Tool-protocol adherence, 145KB preamble (230 tool schemas) | turn 1 → one block, valid `run_command`; turn 2 after OBSERVATION → one block, valid `final` | adherence does NOT collapse when the protocol is buried under schemas |
| Effort control mid-conversation | `set_effort` returns the new label on a chat that already has a turn; re-asserting the same value is idempotent | effort can be re-asserted before every send |

"Composer accepts N chars" and "send + adherence at N chars" are different
facts; both are now measured, so turn 1 through ccr and a mid-task
`ReplayNeeded` re-send are both known-good at real Claude Code preamble size.

**One correction worth carrying forward:** ChatGPT renders fenced code as DOM
nodes, so the fence markers are ABSENT from `innerText`. Never parse tool calls
with a markdown-fence regex; read `pre code` elements out of the reply turn.

## Components

### 1. `solwebd` — the daemon (`modules/gptbridge/solwebd.py`)

Owns exactly one browser session for its lifetime, and therefore owns
`profile.lock`. Long-lived because the 27s cold start must not be paid per turn.

HTTP surface, bound to `127.0.0.1` only:

```
POST /v1/chat/completions   OpenAI shape; `stream` true or false
GET  /v1/models             -> {"data":[{"id":"sol-web-instant","object":"model"},
                                          {"id":"sol-web-medium","object":"model"},
                                          {"id":"sol-web-high","object":"model"},
                                          {"id":"sol-web-xhigh","object":"model"},
                                                                                    {"id":"sol-web-pro","object":"model"}]}
GET  /healthz               -> {"ok":bool,"conversations":int,"queue":int,"turn_p50_s":float}
POST /shutdown              releases the profile lock and exits
```

Every request MUST carry `Authorization: Bearer <token>` matching
`~/.overdeck/gptbridge/solwebd.token` (0600, generated on first start). Without
it any local process could drive the owner's ChatGPT account.

**Serial by construction.** The browser is single-seated, so requests are served
from one FIFO queue, concurrency 1. This is a property of the adapter, not a
bug to engineer around — see *Concurrency* below.

**Effort lives in the model id.** Reasoning effort is the main quality/latency
dial and `chat.py:set_effort` already drives it, but the harness has no place to
carry it besides the model id — every other adapter in `presets/adapters.json`
encodes it there (`gpt-5.6-sol-high`, `gpt-5.6-terra-medium`) because presets
cross-check a seat's binding against the adapter's `models` list. A single
`sol-web` id would make a cheap reviewer seat and a coder seat
indistinguishable, contradicting the per-seat effort policy.

Model ids, one per `chat.py:EFFORT_LABELS` key:

```
sol-web-instant  sol-web-medium  sol-web-high  sol-web-xhigh  sol-web-pro
```

The daemon parses the suffix off `body.model` and rejects an unknown suffix with
HTTP 400. Two efforts MUST NEVER share a conversation, so the effort string is
part of `session_key`.

`set_effort(conv.effort)` is re-asserted immediately BEFORE EVERY SEND, not once
at conversation open. Whether the effort control is per-conversation or
composer-global is a ChatGPT UI detail this design must not bet on: several
conversations coexist in the Registry within one daemon lifetime (an `ask-gpt`
one-shot, and every `ReplayNeeded` that opens a fresh chat while the old one is
still registered), so a global control would silently re-effort them all.
Re-asserting is a no-op if the control is per-conversation and correct if it is
global. Measured: `set_effort` is reachable and settable on a conversation that
already has a turn, and setting the value it already holds is idempotent.

### 2. `translate.py` — stateless `messages[]` ⇄ stateful conversation

The client re-sends the whole history every request; the browser conversation
already holds it. Bridging that mismatch is this module's whole job.

```
session_key(messages, tools, effort) -> str
    sha256 over the system message text + the first user message text
    + sorted tool names + the effort string, hex, first 16 chars. Stable across
    a client session, distinct across tasks AND across efforts.

class Registry
    get(key) -> Conversation | None
    open(key, preamble, effort) -> Conversation   # NEW chat, then the send path below
    Conversation: {url: str, delivered: list[dict], turns: int, opened_at: float,
                   effort: str}

deliver(conv, messages) -> str
    Returns ONLY the text that must be typed this turn.
    `conv.delivered` is the message prefix already sent. If `messages` starts
    with that exact prefix, render and return the suffix. If it does not
    (the client compacted or rewrote history), raise ReplayNeeded.
```

`ReplayNeeded` → open a fresh conversation and render the full history into the
preamble. Correct-by-construction rather than clever: a desynced conversation is
never patched up, it is replaced.

**Turn envelope** — the exact text typed into the composer for a delta. Pinned
because the parser and the model both depend on it:

```
TOOL RESULT [<tool_call_id>]
<content>

TOOL RESULT [<tool_call_id>]
<content>

USER
<content>
```

A tool result whose content exceeds `MAX_RESULT_CHARS` (60000) is truncated in
the middle with `\n…<N> chars elided…\n`; the composer proved good to 82KB, and
this keeps one oversized file read from consuming the whole budget.

### 3. `protocol.py` — tool schemas in, tool calls out

```
render_preamble(system: str, tools: list[dict]) -> str
    The protocol contract + each tool's name, description and JSON Schema,
    followed by the client's system prompt.

parse_reply(blocks: list[str]) -> ReplyKind
    ReplyKind = ToolCall(name, arguments: dict) | Final(text) | Malformed(reason)
    Exactly one JSON block => parse. Zero blocks => Final(prose).
    Two or more blocks, or invalid JSON, or an unknown tool name => Malformed.
```

The wire shape the model is instructed to emit — one fenced json block, nothing
else:

```json
{"tool": "<tool name>", "arguments": {}}
```

```json
{"tool": "final", "text": "<answer>"}
```

`Malformed` triggers ONE correction turn in the same conversation naming the
exact violation. A second `Malformed` fails the request with HTTP 502 — the
harness reads that as engine trouble, which is what it is.

A `ToolCall` becomes a normal OpenAI response with `finish_reason:"tool_calls"`
and one entry in `tool_calls`, `id` = `call_<turn>_<8 hex>`. A `Final` becomes
`finish_reason:"stop"` with the text as content. Claude Code and ccr then see an
ordinary provider.

### 4. Streaming

Claude Code always sets `stream: true`, and a turn takes 10–20s. The shim MUST
NOT sit silent: it emits an SSE comment keep-alive (`: ping\n\n`) every 5s from
the moment the request is accepted, then the complete answer as a single delta
chunk, then `data: [DONE]`. Fake streaming is honest here — the browser has no
token stream to forward.

### 5. `sw` — the wrapper (`modules/harness/wrappers/sw.sh`)

Satisfies `spec/WRAPPER-CONTRACT.md` exactly. Modelled on `wrappers/na.sh`,
which already does the ensure-proxy-then-dispatch dance.

```
sw.sh --workspace <dir> --trust <prompt> --task-slug <slug> [--model sol-web-<effort>]
      [--timeout <secs>] [--session-id <id>] [--health] [--list-models]
```

Order of operations:

1. Ensure `solwebd` is up (start it if not, wait for `/healthz`). Down and
   unstartable → exit `3`, no dispatch.
2. Ensure `ccr` is up via the tested `~/.claude/workflows/lib/ccr-up.sh`.
3. Run Claude Code in the FOREGROUND under `timeout -k 5`, stdin `</dev/null`,
   routed at ccr with the requested `sol-web-<effort>` model (default
   `sol-web-medium`).
4. Log the raw stream to a per-run logfile; stdout carries only the final
   status JSON line.

Exit-code mapping — the part that must not be improvised:

| condition | exit |
| --- | --- |
| Claude Code ran to completion | `0` |
| `--timeout` elapsed / killed | `124` |
| bad or missing args | `2` |
| `solwebd` or ccr unreachable, or the ChatGPT session is logged out | `3` |
| ChatGPT usage cap hit | `75`, with `resume_at` in the status JSON |

`--timeout` defaults to **3600**, not 360. A sol-web task is 10–20s per tool
step by measurement; the na.sh default would kill nearly every real task.

**Rate limits.** `solwebd` detects the usage-cap state in the DOM and answers
HTTP 429 with
`Retry-After` and a `resume_at` body, and `sw.sh` maps that to exit `75`. Without
this a quota wall reads as a defect and the harness retries straight into it.

Cap detection is NEW work — `chat.py` today only steers between the Chat and
Work surfaces (`use_chat_surface`) and has no cap probe. Capture the cap DOM
against the live account before implementing; do not guess its selector.

### 6. Registration

ccr provider (`~/.claude-code-router/config.json`, one entry appended):

```json
{"name": "solweb",
 "api_base_url": "http://127.0.0.1:8791/v1/chat/completions",
 "api_key": "<contents of solwebd.token>",
 "models": ["sol-web-instant", "sol-web-medium", "sol-web-high",
            "sol-web-xhigh", "sol-web-pro"]}
```

Harness adapter (`modules/harness/presets/adapters.json`, one entry appended):

```json
{"id": "solweb", "wrapper": "wrappers/sw.sh",
 "description": "ChatGPT web session (Sol) driving Claude Code via ccr.",
 "listModels": true, "metered": false,
 "models": ["sol-web-instant", "sol-web-medium", "sol-web-high",
            "sol-web-xhigh", "sol-web-pro"],
 "safeProbeArgv": ["--health"]}
```

**Two facts about the layers this design does not own:**

- `ccr-up.sh <slug>` takes the slug ONLY to name its daemon log
  (`$CCR_TMP_ROOT/ccr-<slug>.log`) and validates nothing against a provider
  list — verified in its contract header. `bash ccr-up.sh solweb` is therefore
  safe before the provider exists.
- ccr's Anthropic→OpenAI translation for a bare provider (no `transformer` key)
  is the ONE layer this design owns neither side of. Before writing `solwebd`,
  prove against a stub server on :8791 that ccr forwards `tools` through and
  accepts a synthesized `tool_calls` response. A failure here changes the
  provider entry, not the architecture — but find it with a stub, not with the
  browser.

### 7. `ask-gpt` becomes a daemon client

Today `ask-gpt` opens its own session and takes `profile.lock`. With `solwebd`
running that blocks for `LOCK_WAIT_SECONDS` (600) and then fails — the two tools
would fight over the one browser.

So: `ask_gpt.py` probes `/healthz` first. Daemon up → the prompt goes through it
as a one-shot conversation. Daemon down → today's direct path, unchanged. The
owner's `ask-gpt "draw me a cat"` keeps working either way, and image harvesting
stays in `chat.py` where it already is.

## Concurrency — a stated limit, not a hidden one

`spec/adapters.schema.json` has no per-adapter concurrency key
(`additionalProperties: false`), and the engine's pool is run-level
(`scheduler.js:310`). There is nowhere to declare "this adapter is serial".

Therefore, explicitly: **bind any `sol-web-*` model to at most one seat per wave** (the limit is the
browser, so it spans every effort, not one id). The
daemon's queue keeps a second concurrent task correct — it waits rather than
corrupting the conversation — but it also makes wall-clock additive, and a long
enough wait ends as a `124` the engine retries into the same queue.

`/healthz` exposes `queue` so this is observable rather than mysterious. Adding
a real per-adapter cap to the adapter schema is a separate change to the engine
contract and is deliberately not bundled here.

## Failure modes and the response to each

| failure | detection | response |
| --- | --- | --- |
| Client compacted history mid-task | prefix mismatch in `deliver` | `ReplayNeeded` → fresh conversation, full replay |
| Model drifts off the protocol | `parse_reply` → `Malformed` | one correction turn, then 502 |
| ChatGPT context window exhausted | model reply degrades / errors in DOM | fresh conversation, replay compacted history |
| Session logged out | `wait_ready` fails / login DOM | 503 → wrapper exit `3` |
| Usage cap | cap DOM state | 429 + `resume_at` → wrapper exit `75` |
| Browser or Xvfb dies | Marionette connection error | daemon rebuilds the session once, then 503 |
| Daemon killed, orphans left | existing `reap_orphans` | already handled on next session open |

## Security

The daemon is a local HTTP endpoint that can drive the owner's real ChatGPT
account and, through Claude Code, edit files. Non-negotiable:

- Bind `127.0.0.1` only. Never `0.0.0.0`.
- Bearer token required on every route including `/healthz`; token file `0600`
  under `~/.overdeck/gptbridge/`.
- Everything the daemon writes stays under `~/.overdeck/gptbridge/`.
- Filesystem confinement is Claude Code's own permission system, in the
  workspace the wrapper `cd`s into. `sandbox.py`/`workspace.py` guard the MCP
  tunnel path and are NOT in this path — do not wire them in halfway.

## Testing

| level | test | assertion |
| --- | --- | --- |
| integration | effort in force | request `sol-web-high`; assert the effort label reads "high" at send time, not merely that the suffix parsed |
| unit | `session_key` stability | same system+first-user+tools+effort → same key; any change, effort included → different key |
| unit | `deliver` prefix logic | matching prefix → suffix only; rewritten history → `ReplayNeeded` |
| unit | `parse_reply` | one block → ToolCall; zero → Final; two/invalid/unknown → Malformed |
| unit | envelope rendering | multi-result + user turn renders the pinned shape; oversized result elided |
| contract | `wrappers/test/` alongside `ca-identity.test.sh` | every exit code reachable with the daemon stubbed |
| integration | daemon with a scripted fake browser | full tool loop without touching the real account |
| live | `sw --workspace <tmp> --trust "create hello.txt containing hi"` | file exists, exit 0 |

The fake-browser integration layer matters most: everything above it must be
testable without spending a real ChatGPT turn.

## Architecture Decisions

Three modules pass the deletion test and are kept:

- `solwebd` — delete it and the 27s cold start returns per turn, the profile
  lock is contended by every caller, and there is nowhere to queue. Deep: the
  client cannot tell a browser sits behind it.
- `translate.py` — delete it and stateless/stateful reconciliation scatters into
  the request handler. Medium-to-deep: `Registry`'s internals are replaceable
  (a different keying or replay strategy) without touching callers.
- `protocol.py` — delete it and prompt synthesis plus reply parsing smear across
  the handler. Deep: swapping the wire protocol (say, to XML tags if JSON
  adherence ever degrades) touches nothing else.

Rejected as decorative: a provider-abstraction layer over "OpenAI-compatible
clients". There is exactly one client shape and one endpoint — a single-adapter
seam, collapsed per YAGNI. codex and opencode can be pointed at the same
endpoint by config alone; no code seam is needed for them.

Also rejected: reusing `sandbox.py`'s bubblewrap jail for tool execution. Claude
Code owns tool execution and its own permission model here; interposing a second
sandbox would half-confine a loop we do not run.

## Corrections the live end-to-end run forced

Each of these replaced a design assumption with a measurement, on 2026-08-08.

- **The effort menu offers Instant, Medium, High, Extra High, Pro.** There is no
  `light` and no `max`; `pro` is labelled `pro`, not `ultra`. Model ids are
  `sol-web-{instant,medium,high,xhigh,pro}` — five, not six.
- **SSE keepalives must be real chunks, never `: ping` comments.** ccr parses every
  data event and cancelled the stream when a browser turn (10-120s) produced none;
  Claude Code reported `aborted_streaming` and retried forever. `keepalive_chunk()`
  emits an empty-but-well-formed delta instead, and the first one carries the role.
- **A replayed conversation always types the whole history.** `deliver` raises
  `ReplayNeeded` on a history that adds nothing, which is correct for a live chat and
  wrong for a fresh one; `translate.deliver_all` is what a fresh conversation gets, and
  it never raises.
- **A closed Marionette socket is a `MarionetteError`, not an `AssertionError`.**
  Only the former reaches the seat's rebuild-once path.
- **One turn is bounded by `SOLWEBD_TURN_TIMEOUT` (default 300s).** An HTTP client
  that walks away cannot cancel a Marionette call in flight, so the turn timeout is
  also the longest an abandoned request can hold the single browser seat.
- **`ask-gpt` reaches the daemon over `POST /v1/ask`**, which runs `chat.ask_on`
  under the same lock agentic turns take. Image harvesting stays in `chat.py`;
  attachment and output paths are sent absolute because the daemon's cwd is not the
  caller's.
