The first draft pinned the gate to Step 21. Wrong distinction: the gate lives in the launcher, so it covers tools reached through the MCP server and not a skill that calls a script directly, which is how Steps 21 and 22 write. Step 21 is where the defect starts; it is not where the gate has to land. Pinned to Step 24 instead, with the one-file fence amendment named. Co-Authored-By: Claude <claude-opus-5>
26 KiB
Cowork probe
Plan Step 1 (M1). An empirical record of what this operator's Cowork installation actually does, measured before any code depends on it.
Operator decision 7 — Python as a CLI plus a host-side stdio MCP server — is void if host-side servers never appear in a Cowork session. That is not knowable from Claude Code, so it is measured here first, and the milestone that depends on it carries a written degradation branch either way.
Status: unanswered. All four questions require the operator sitting in a Cowork session on this Mac. The executor session built the probe vehicle and this checklist; it did not and could not answer them. The verify command for this step is written so an unanswered checklist fails:
test "$(grep -cE '^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT): +(lokal VM|sky|ja|nei)\b' docs/cowork-probe.md)" = 4
A bullet still reading ubesvart does not match, the count drops below four,
and the command exits non-zero. That is the intended behaviour until the
operator has run the probe.
Results
Overwrite ubesvart with one of the allowed answers, and put the date of the
measurement in the Measured column. A nei is a result, not a failure — it
selects the degradation branch below.
- Sesjonsmodus: sky
- Host-MCP: ja
- python3: ja 3.11.15
- CLAUDE_PLUGIN_ROOT: ja
| Bullet | What the operator does in Cowork | Allowed answer | Measured |
|---|---|---|---|
- Sesjonsmodus: |
Open a Cowork session and read whether it is a local VM or cloud | lokal VM or sky |
2026-09-04 -- read off the measurement, not the settings screen: the sandbox reports /root/... paths and [GCC 13.3.0] |
- Host-MCP: |
Upload jobbsok-probe.plugin via Customize -> Plugins; does probe-tools appear as a connector, and does probe_ping return? |
ja or nei |
2026-09-04 -- both halves hold: connected per main.log, and probe_ping returned probe-tools 0.0.1, python 3.14.0 @ /usr/local/bin/python3 |
- python3: |
Run the probe-versjon skill; does it succeed in invoking python3, and which version does it report? |
ja or nei, followed by the reported version |
2026-09-04 -- 3.11.15 (main, Mar 3 2026) [GCC 13.3.0], the SANDBOX interpreter, not the host's |
- CLAUDE_PLUGIN_ROOT: |
Run probe-versjon; does it print a resolved absolute path, or the literal variable name? |
ja or nei |
2026-09-04 -- /root/.claude/plugins/synced/<session>_<account>/jobbsok-probe, and the path exists |
Two-run record: is the session-location setting the lever?
Cowork exposes Only on this computer as a toggle, which means session mode
is a setting rather than a property of this machine -- something the four
bullets above cannot express, since each holds one value. Anthropic's
documentation says local MCP servers do not run in cloud sessions, and cloud has
been the default since 2026-07-07; whether a cloud-default session on this Mac
still spawns host-side servers via the desktop app is recorded as unverified
in research/03-cowork-plugin-compatibility.md, dimension 4. So run the probe
twice and fill this in before writing the bullets above. The off run is the
cheap one to lose: it is the current default, and a fresh install returns to it.
| Run | Only on this computer |
probe-tools listed? |
probe_ping answers? |
Manifest version | Date |
|---|---|---|---|---|---|
| 1 | off (current default) | yes | yes -- returned host Python 3.14.0 | 0.0.1 | 2026-09-04 |
| 2 | on | not run -- see below |
How to read the pair:
| Run 1 (off) | Run 2 (on) | What it means |
|---|---|---|
| no | yes | The toggle is the lever. Operator decision 7 works, but only with it on -- which then belongs in README.md and in every Verifiser i Cowork step, not in a footnote. |
| no | no | Decision 7's Cowork half is dead whatever the setting. The degradation branch below applies, and jobbsok-tools is not built on an assumption. |
| yes | yes | Host-side MCP runs even in cloud mode. That contradicts the documentation and is the most interesting of the three -- research 03 flagged exactly this as unverified. Record what you saw, verbatim. |
Then write the session-mode bullet above as the mode that produced the better result, and put what the other run gave in its Measured column. If both runs agree, say so there too: an agreement that was measured reads differently from one that was assumed.
What is already known, and what is not
Measured 2026-09-04 from Claude Code, before the probe was built:
- Cowork is installed and available on this Intel Mac.
- The session mode is not readable from disk.
cowork_settings.jsoncarries onlyextraKnownMarketplaces; the desktop config carriescoworkNetworkModeand a trusted-folder list, and neither states where a session runs. So- Sesjonsmodus:cannot be answered by reading files — it has to be read off a live session. - The other three each require a plugin installed in a live Cowork session. Nothing about them is decidable from Claude Code.
The probe vehicle
jobbsok-probe — a throwaway test plugin, deliberately not the jobbsok
plugin itself. jobbsok's own .mcp.json (Step 12) and packaging script
(Step 13) land eleven steps later, and this probe must not wait for them.
It holds exactly four files, under tests/fixtures/cowork-probe/:
| File | What it is for |
|---|---|
.claude-plugin/plugin.json |
Manifest. Name jobbsok-probe, version 0.0.1. The version is what probe-versjon echoes, so a mismatch reveals a cached build. |
skills/probe-versjon/SKILL.md |
A skill that reports the manifest version, the resolved ${CLAUDE_PLUGIN_ROOT}, and whether it can invoke python3. Answers three of the four questions. |
.mcp.json |
Declares one stdio server, probe-tools. Its appearance in the session is the whole - Host-MCP: question. |
probe_tools.py |
Standard-library JSON-RPC server exposing one tool, probe_ping, returning the interpreter actually running it. No third-party imports: a server that failed on a missing dependency would answer a different question than the one asked. |
Verified from Claude Code 2026-09-04: the server completes an initialize
handshake, lists probe_ping, returns an interpreter version on
tools/call, and rejects an unknown tool name. What is unverified is whether
Cowork ever starts it.
Building the archive
Run from the repository root. The file list is explicit, and that is the point:
cd tests/fixtures/cowork-probe && \
zip -X /tmp/jobbsok-probe.plugin \
.claude-plugin/plugin.json \
.mcp.json \
probe_tools.py \
skills/probe-versjon/SKILL.md
Never zip -r over the repository root. A recursive archive of the repo would
ship .git, STATE.md, .claude/ and the virtualenv into an uploaded plugin.
The include list above names the four files and can carry nothing else.
Running the probe, step by step
Everything below happens in Claude Cowork on this Mac, not in Claude Code.
Where a UI path is named it comes from Anthropic's own documentation
(research/03-cowork-plugin-compatibility.md, dimensions 1 and 6); where the
wording in the app turns out to differ, trust the app and note what you saw --
a changed menu label is itself a finding worth recording.
A. Read the session mode -> answers - Sesjonsmodus:
Open Cowork and find its session-location setting (Cowork -> Settings). You are looking for whether sessions run on this machine or in the cloud.
- On this machine: shell commands execute in a dedicated local Linux VM (Apple Virtualization.framework), which needs working hardware virtualization; Claude runs a readiness check for exactly that.
- In the cloud: code runs on Anthropic's servers. This has been the default since 2026-07-07, so do not assume local just because the Mac is capable.
Write lokal VM or sky.
Do not just read this setting -- probe both sides of it. It is a toggle, so the documented claim that local MCP servers do not run in cloud sessions is testable here rather than merely quotable. Leave it as you found it for the first pass, run sections B through E, then flip it, start a fresh session and run B through E again. Record both in "Two-run record" above; that table also says how to read the pair. Five minutes buys the difference between "Cowork cannot do this" and "Cowork can do this if one setting is on", which are very different findings for the milestone.
One honesty marker: that this toggle controls session location is an inference from its own label and subtitle -- a local VM stops when the machine sleeps, a cloud session does not -- and no documentation read so far names this exact string. The probe is what settles it.
B. Install the probe plugin (needed for C, D and E)
Two documented routes; either is fine.
- Customize -> Plugins, choose the install-from-file / upload option, and pick
/tmp/jobbsok-probe.plugin. - Or drag the
.pluginfile into the Cowork chat. Anthropic's own plugin packaging skill says a.pluginfile appears in the chat as a rich preview with a button to accept it.
Known wrinkle, not your mistake if it happens: uploaded plugins have been reported not to survive an app relaunch (claude-code#65426, June 2026). If the plugin disappears later, re-upload it rather than concluding anything about the probe.
C. Confirm you are testing the build you just uploaded
In the Cowork chat, type / (or +) to list available skills, and run
probe-versjon.
The first thing it reports is the manifest version. It must be 0.0.1.
If it reports anything else, Cowork served a cached build rather than the one you uploaded (claude-code#69020, open) -- and then every answer below is about some other build. Uninstall, re-upload, and re-run before continuing. This check exists because a stale cache produces confident, wrong measurements.
D. Read the skill's output -> answers - python3: and - CLAUDE_PLUGIN_ROOT:
Both come from that same probe-versjon run.
- python3: -- did the skill succeed in running
python3 -c 'import sys; print(sys.version)', and what did it print?
Write ja followed by the version, or nei followed by the error text.
One thing to be precise about, because it changes what the answer means: in
Cowork, shell commands run inside the sandbox (the local VM, or the cloud
environment), not on your Mac. So this measures the sandbox's python3,
not the host's. That is the right thing to measure here -- it tells us whether
a skill-invoked script can run in Cowork at all -- but it is not evidence about
the interpreter that scripts/bootstrap.sh just built a virtualenv for.
- CLAUDE_PLUGIN_ROOT: -- did the skill print a resolved absolute path, or
the literal unexpanded string ${CLAUDE_PLUGIN_ROOT}? Write ja or nei.
An absolute path is the pass; anything literal or empty is nei.
E. Look for the connector -> answers - Host-MCP:
This is the load-bearing one: it is the entire Cowork half of operator decision 7, and the reason this probe is the first step of the build.
A server declared in .mcp.json does not show up as a skill. In Cowork it
appears as a Connector. Two checks, and both must hold:
- Is it listed? Customize -> Plugins, open
jobbsok-probe, and look at its connectors page. Isprobe-toolsthere? - Does it answer? In the chat, ask Claude to call the
probe_pingtool. It should return JSON containingpython_versionandpython_executable.
Write ja only if both hold. If probe-tools is listed but never answers,
write nei and note that it appeared but did not respond -- a connector that
does not respond is not a working host-side server, and the difference between
"absent" and "present but mute" points at different causes.
Unlike D, this one does run on your Mac: Anthropic moved local MCP servers
out of the sandbox in May 2026 and runs them as ordinary host software. So a
ja here means the host Python, the host filesystem and ~/jobbsok-workspace
are all reachable from Cowork -- which is what decision 7 is built on.
F. Record and verify
Edit the four bullets under "Results" above, replacing ubesvart, and fill in
the Measured column with today's date. Then, from the repository root:
test "$(grep -cE '^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT): +(lokal VM|sky|ja|nei)\b' docs/cowork-probe.md)" = 4
Exit 0 means the probe is complete and plan Step 1 can be closed. Exit 1 means at least one bullet is still unanswered or was written in a form the grep does not recognise -- check that the answer follows the label directly, with a space, and starts with one of the four allowed words.
Observations beyond the four questions
Measured 2026-09-04 while running the probe. These are deliberately not
extra bullets: the four above are gated on a count of exactly four, and Step 31
already adds a fifth (- Connector-hook:). A sixth would break the Verify. They
are recorded here because they answer questions later milestones were going to
ask, and because a finding that lives only in a commit message is lost.
O1. Host-side MCP reaches a cloud session, through a bridge
The strongest result of the probe, and it contradicts the reading this plan was
built on. research/03 dimension 4 quotes Anthropic: local MCP servers "don't
run in sessions in the cloud", and flagged as unverified whether a
cloud-default session on this Mac still spawns them. It does.
Measured from the host, not recalled from a UI:
main.log 2026-09-04 18:48:28
[LocalMcpServerManager] Connecting to plugin:jobbsok-probe:probe-tools
[LocalMcpServerManager] ... negotiated protocol version: 2025-11-25
[LocalMcpServerManager] Connected to plugin:jobbsok-probe:probe-tools (1 tools)
[localMcpBridge] announcing plugin:jobbsok-probe:probe-tools: 1 tool(s)
ps: 12997 Claude.app/Contents/Helpers/disclaimer -- /usr/local/bin/python3
.../rpm/plugin_01NxHfqM495jtJzjVjRvgWDm/probe_tools.py
12998 Python.framework/Versions/3.14/... (the host interpreter)
The mechanism is localMcpBridge, the same one that announces Claude Desktop's
own Filesystem server. The session sees the tool as
mcp__remote-devices__plugin_jobbsok-probe_probe-tools__probe_ping. So
"host-side server" and "cloud session" are not exclusive here: the server runs
on the Mac and is bridged in.
One design choice was vindicated by the log. probe_tools.py echoes the
client's protocolVersion rather than asserting its own default. The client
asked for 2025-11-25, which is newer than the server's fallback; a hardcoded
version would have made this handshake a different and worse story.
The consequence, which is a cost and not only a capability. Because the server runs on the Mac and is bridged in, a plugin-declared MCP tool in Cowork is reachable only from a session that can reach this machine. That is the honest shape of operator decision 7: it buys real host access -- the guard, the workspace, the filesystem -- at the price of tying the Cowork surface to one machine.
Stating the risk precisely matters more than stating it darkly, because an overstated risk gets discounted and takes the real one with it. So:
- "This Mac is asleep" is not the risk here. This operator's Mac does not sleep. Corrected 2026-09-05: what stood here also argued there was no wider user population to generalise to, and that is no longer true. The project has external adopters, each running their own install on their own machine. The build brief's single-operator clause means single-TENANT -- one person searching for their own job, one workspace per install -- and it still holds; what does not hold is using it to dismiss a question about other people's machines. Everything measured below is macOS only.
- "Cowork opened somewhere else" is the risk. The bridge is named
remote-devicesand the server is a child ofClaude.appon this Mac. A session started on a phone or a second laptop was not measured, and whether it reaches this machine's servers should not be assumed in either direction until it is.
So the degradation branch below answers two triggers, not one: a nei on
- Host-MCP:, and Cowork being used away from this Mac.
Also worth recording, because it shaped this script's own check: an individual
tools/call does not appear in main.log. The connection lifecycle does.
grep -c probe_ping main.log returned 0 both before and after a successful
call, so the absence of that line proves nothing -- which is why the check
script says so instead of reading it as an answer.
O2. The sandbox and the host are two different interpreters
| Where | Interpreter | Evidence |
|---|---|---|
| Cowork sandbox (skills, Bash) | Python 3.11.15, [GCC 13.3.0], Linux |
reported by probe-versjon |
| Host (MCP servers) | Python 3.14.0, macOS framework build | ps, and the venv Step 2 built |
${CLAUDE_PLUGIN_ROOT} resolves inside the sandbox to
/root/.claude/plugins/synced/<session>_<account>/jobbsok-probe. Both facts
matter for M3: guard_ingest.py cannot import the guard in the sandbox, so it
belongs on the host side of the bridge, which O1 shows is reachable.
The Linux paths and the GCC build string are also what answer the session-mode question. It was read off a measurement, not off a settings screen.
O3. Cowork's built-in browser reads Finn, authenticated
Operator measurement, 2026-09-04. Cowork's built-in browser (shipped
2026-08-26) rendered a Finn job ad (finn.no/job/ad/<id>) in full -- employer, title,
Soknadsfrist, Mulighet for hjemmekontor, Arbeidsspraak, Sektor: the
exact fields the M3 extraction wants, at a clean snapshot-able URL. The
operator was logged in to Finn, having signed in by hand inside that browser.
This is the brief's invariant reached by a shorter route: the plugin handles no credentials, the operator authenticates manually, and the browser is already authenticated when it reads. It raises a real M3 question -- if the built-in browser covers both public and logged-in sources, what does Playwright MCP over CDP still add? -- but that is an M3 decision and is not taken here.
Not established, and worth knowing before the decision is taken: whether that login persists across sessions or must be repeated each time. Nothing was measured about persistence.
Unlike O1 and O2, this one has no host-side trace: the built-in browser writes
nothing to main.log (checked for finn.no, webview, browserTool --
no hits). The operator's observation is the only instrument.
M1 verifisert
Operator measurement, 2026-09-05, in a Cowork session on this Mac, against a
jobbsok.plugin built by scripts/package_plugin.sh.
| Question | Answer |
|---|---|
Build stamp equals git rev-parse --short HEAD |
yes -- 8a3320b |
kandidatprofil and kandidatvurdering load and run against a pasted listing |
yes, both |
jobbsok-tools appears as a connector |
yes |
selvsjekk reports an interpreter at 3.10 or newer |
yes -- 3.14.0 |
selvsjekk returned, with the workspace path withheld because this remote is
public:
| Field | Value |
|---|---|
server |
jobbsok-tools |
server_versjon |
0.1.0 |
python_executable |
the host interpreter, under /usr/local/bin |
python_version |
3.14.0 |
guard_versjon |
null |
build_stamp |
8a3320b |
workspace |
an operator-chosen directory outside this repository |
The version echo reported the build stamp 8a3320b and manifest version
0.1.0.
The degradation branch did not fire. - Host-MCP: was already ja from the
probe, and this milestone's own build confirms it on the real plugin rather than
on a throwaway one: a plugin-declared stdio server appears as a connector and
answers. Operator decision 7 holds on both halves.
No cached build, and the comparison earned its place. The tool server runs
from a per-session copy on the Mac; the skill files are read from the synced
copy. Two different locations, and both reported 8a3320b. That is the pair
this check exists to compare, and it is informative only because it could have
disagreed.
O4. The launcher gates on interpreter version, not on the guard
guard_versjon came back null: the server is serving without the ingestion
guard. The cause is measured rather than inferred --
scripts/jobbsok_tools_launch.sh contains no reference to the guard at all, and
resolves an interpreter in four steps ending at python3 on PATH, "only at 3.10
or newer". In an installed copy the first three branches cannot fire:
JOBBSOK_PYTHON is unset, no environment has been built under
${CLAUDE_PLUGIN_DATA} because scripts/bootstrap.sh was not run against the
installed plugin, and the packaged archive deliberately excludes the virtualenv.
So branch four resolved the host interpreter at 3.14.0, which clears the version
floor and serves.
Step 12's stated reason for that floor was that "a server running on 3.9.6 without the guard is worse than no server". The thing being protected is named there as the guard; the mechanism only checks the version. An interpreter at 3.14.0 without the guard passes a gate written to prevent exactly that outcome.
At M1 this costs nothing, and the reason is narrow rather than reassuring: no
tool at this milestone writes. annonse_vurder scores and returns, so the
pasted listing never became a file. Write-freedom is the whole of the
protection. M2 adds the decision log and the case folder, which do write, and
from that point guard_versjon: null is a defect rather than an observation.
Two things follow, and they should not be merged into one:
- Setup. Running
scripts/bootstrap.shagainst the installed copy builds the${CLAUDE_PLUGIN_DATA}environment the launcher prefers, and the guard arrives with it. That route is documented; it was simply not run here. - The gate. That setup is skippable is the point. A launcher which refuses
an interpreter below 3.10 but serves willingly without the guard leaves the
milestone that introduces writes depending on an operator remembering a step.
Whether
selvsjekkreportingnullis sufficient, or whether the launcher should refuse outright once a writing tool exists, is an M2 decision and is not taken here.
O4 decided (M2, 2026-09-05): the launcher refuses without the guard
Decision. The gate moves onto the guard. From the step that introduces the
first write of untrusted-derived content, jobbsok_tools_launch.py refuses to
serve when llm_ingestion_guard cannot be imported by the interpreter it
selected, and says so in words naming python scripts/bootstrap.py. The
version floor stays; it is now the second of two gates, not the only one.
selvsjekk keeps reporting guard_versjon, because a gate and a report answer
different questions -- the gate decides whether to serve, the report tells the
operator which build answered.
Why not "report it and let it run". The measured incident is exactly the
case where nobody asked. The server was serving, guard_versjon was null,
and it stayed null until a probe went looking. A signal that only fires when
someone requests it is not a gate; it is documentation with a return value.
Why refusing is cheap here, and this is the deciding argument. Every skill
in this plugin degrades to manual paste with jobbsok-tools absent -- that is
a build-brief invariant, not a hope. So a launcher that refuses does not strand
the operator: it removes the connector and leaves the skills working the way
they already work on a machine that never installed the server. The cost of
failing closed is a connector that does not appear. The cost of failing open is
untrusted text persisted through the one boundary this plugin exists to hold.
What does NOT trigger it, stated so the rule is not widened by accident.
M2's first write is sak_status.py --oppdater, and it writes four derived
values -- a state name and a waiting party from a closed enum, and two dates
computed from timestamps. No untrusted byte reaches that file through that
path, so the write-back is not what makes guard_versjon: null a defect. The
defect starts at plan Step 21 and Step 22, where sak.md and
beslutninger.jsonl first persist employer names, titles, URLs and notes that
came from a listing.
Timing, and one distinction the first draft of this section got wrong.
The gate lives in scripts/jobbsok_tools_launch.py, so it protects exactly one
path: tools reached through the MCP server. It does not protect a skill that
calls a script directly, which is how sak and beslutning write in Steps 21
and 22. So Step 21 is where the defect starts and it is not where the gate
has to land; the hard deadline is the first time the server exposes a tool that
persists untrusted content, which Step 24's status and daily tools are not.
Implemented at Step 24 anyway, because that is the next step that touches
this neighbourhood and shipping the gate before it is needed costs nothing.
Step 24's fence (plan Session 6) lists scripts/jobbsok_tools.py and must be
extended by one file to include the launcher; the order that carries Step 24
says so explicitly. A decision recorded without an implementation step is a
decision that evaporates; this one has a step number and a fence amendment.
Degradation branch
Written now, so the answer does not get decided under pressure later.
If - Host-MCP: is nei — host-side stdio servers do not appear in this
Cowork installation:
- Operator decision 7's Cowork half is unavailable. Nothing changes for Claude Code, where the scripts are invoked directly.
- Every script-backed skill degrades to reading files that the Claude Code CLI produced. The skills stay useful; they stop being able to compute.
Verifiser i Coworkfor the affected milestones means "the skills load and read files produced from Claude Code" — not "the connector answers".- The degradation is recorded in
STATE.md.
This is risk H12's mitigation, and it is why the probe is the first step of the build rather than a later check.
If - python3: is nei — a skill cannot invoke python3 in a Cowork
session: the same degradation applies, and more broadly, since it removes the
CLI half too. Escalate to the operator before building further on decision 7.
If - CLAUDE_PLUGIN_ROOT: is nei — the variable does not resolve: every
intra-plugin path in the plan is affected, not just this milestone's. Escalate;
do not paper over it with hardcoded paths.
If - Sesjonsmodus: is sky — sessions run in the cloud, and host-side
servers cannot reach this machine at all. - Host-MCP: should then read nei,
and its branch applies. If the two disagree, the disagreement is the finding.