Verified 2026-08-05
Extending · 19 rules

Will my plugin's hooks and scripts run in Cowork?

Yes — plugin hooks do fire in Cowork. The determinant is where the plugin was installed from: a plugin installed through the Cowork application is visible to Cowork sessions, and one installed through the CLI's own path is not, because they occupy different namespaces. The plugin-root token resolves differently depending on which consumer expands it, so use it for file-tool reads and discover the mounted path at runtime for anything you shell out to.

Do not use a hook to export environment variables for your skill's shell commands.

SILENTMeasured

Hooks run, but the environment they modify is not the environment those commands see. This is a common way a plugin appears broken in Cowork while working in the CLI: the hook fires, the export succeeds, and the value is simply absent downstream.

Expect plugin hooks to fire in Cowork.

FRICTIONMeasured

Hooks shipped by a plugin do run. If you have heard otherwise, the determinant is installation namespace rather than hook support: a plugin installed where Cowork sessions do not look will appear to have no hooks at all.

Caveat: In the remote sandbox one instrumented run saw a plugin's session-start hook fire only when the session resumed, not on the first turn, while the same plugin's stop hook fired normally. Do not depend on session-start work having happened before the first turn there.
Caveat: A stop hook that blocks shows the user nothing in the app: no notice, no error — the first reply stays visible and the corrected one appears beneath it. If your hook blocks, make the corrected reply self-explanatory.

Install a plugin through the Cowork application if you want Cowork sessions to see it.

Plugins installed through the CLI's own installation path land in a namespace that Cowork sessions never read, so the plugin appears installed and is simply invisible. This is the usual explanation for 'my plugin works in the CLI but not in Cowork'.

Do not write into your plugin's own directory at runtime.

Plugin content is mounted for reading. Treat everything shipped with the plugin as immutable during a session and put generated state in the outputs location.

Expect plugin files to be present as a mounted directory, and find it rather than hardcoding it.

Plugin content is made available to the session as its own mount. The exact location embeds identifiers that vary per plugin and per install source, and the shape has changed across releases, so discovery at runtime is the only stable approach.

Use ${CLAUDE_PLUGIN_ROOT} for file-tool reads, and discover the mounted path at runtime for anything you execute.

SILENTFrom binary Local sandbox

${CLAUDE_PLUGIN_ROOT} is a token that expands to your plugin's directory — but it expands differently depending on what expands it and which sandbox the session is in, and one expansion names a location the other cannot reach. Reading your plugin's files through the file tools with the token is reliable only where the runtime replaces it for you, which it does in the files it loads as definitions — your skill's own body, a command, a hook. There the token is swapped for a real path before the model ever sees it. It is not replaced in a file your skill merely reads later while it runs: a reference file that instructs the model to use the token hands over the token itself, which names nothing. This is the difference between text the runtime loads and text your skill reads, and it has cost a real skill a silent fallback to a degraded path. Executing a script by that path is not: in the local sandbox the substituted value names a host staging directory the shell cannot see. And do not test for the obvious failure — it is not the one you get. The token is often *present* in a shell, holding a different plugin's directory: hooks can write environment for later shell commands, each hook is given its own plugin's location, and the last one to write wins. So a shell reading it typically fails as a plausible wrong path, not as a blank — checking whether it is empty comes back clean and sends you looking in the wrong place. For anything you execute, do not read the token at all: ship a launcher (next rule), and fall back to locating the plugin's directory at run time.

Caveat: Resolution differs between the two sandboxes; this rule is written to be correct in both.
Caveat: The reliable case is narrower than it reads: substitution happens in the files the runtime loads, not in files your skill reads at run time. If your skill points the model at a reference file, put a real located path in it rather than the token, and have the step that cannot find what it needs stop and say so — a resolution that fails quietly is indistinguishable from a feature that was never shipped.

Ship a launcher in your plugin's bin/ and call it as a bare command.

FRICTIONMeasured

This is the one way to run your own bundled code that does not require any path to survive a trip between the model and the shell. Every enabled plugin's bin/ directory is placed on the shell's command path, and the entry is correct for the namespace that shell actually lives in — measured on three different surfaces, including the one where the file tools and the shell disagree about every other path. Because the shell resolves the name itself, nothing has to be derived, passed across, or rewritten. A launcher is a few lines: locate your own directory from the script's own position, then run the real program beneath it. Your skill then says mytool <args> instead of constructing a path it has no reliable way to construct. Every link is verified: the path entry is live rather than nominal (a command dropped into the directory resolves immediately, without restarting anything), and a launcher committed in your source survives installation intact. What it is not, yet, is common: of the ninety-seven plugins installed on the machine where this was checked, not one puts a launcher where the command path looks for it. So confirm your first use resolves rather than assuming it — and read that number as "nobody has tried this yet", not as "people tried and it failed".

Caveat: Being on the command path is not a promise the directory exists. The entry is added for every enabled plugin whether or not the directory is there, so the obvious check — look for your plugin in the command path — reads healthy on exactly the failure you are chasing. Confirm a first use by asking the shell to resolve the command itself.
Caveat: Put it beside your plugin manifest. A directory of the same name beside your README, or inside a source subtree, is a normal thing for a software project to have and is not read by anything here — so do not move an existing one to satisfy this; add the plugin's own.
Caveat: If your plugin's manifest declares a command-line tool of its own, the runtime writes a launcher of that exact name into the very directory you would put yours in, on its own schedule — so give your launcher a different name than anything you declare. Whether a differently-named one survives being re-provisioned alongside it has not been tested.
Caveat: The directory is mounted read-only, so a launcher cannot write next to itself; a plugin installed under a path containing shell punctuation is dropped from the command path silently; and the execute bit has to be committed, because it cannot be added at run time.

${CLAUDE_SKILL_DIR} works in your SKILL.md body and nowhere else.

SILENTFrom binary Local sandbox

It is the variable most skill authors reach for to point at their own bundled files, and in the body of SKILL.md it does exactly what you want: it is replaced with a real absolute path before the model ever reads the text, so a file tool opens the right file. There are four places it will quietly do nothing instead. In a shell command it is not set at all, so it becomes empty and the command addresses a path at the root of the filesystem. Written without braces it is not recognised and survives into the loaded text as the literal characters you typed. And in a reference file your skill opens while it is running — as opposed to the definition the runtime loads at startup — it is never substituted, so it arrives as the characters you typed. And in a command file — which is definition text the runtime loads, and where the other tokens are replaced normally — this one alone is skipped. One rule covers all four: it belongs to the body of a skill specifically, not to definition text in general, and not to anything that can be looked up later. Once a path has to reach a shell, either the model passes an absolute path it has already resolved, or you ship a launcher that finds its own location.

Caveat: It is also skipped when the skill has no directory of its own to resolve, in which case the token survives unchanged rather than raising anything.
Caveat: The other tokens replaced nearby do NOT hold on the same terms, and assuming they do is the trap. The session id, the effort level, your plugin's directory and the project directory are all replaced without that restriction — including inside a command file, where this one is not. So the answer is specific to the token, never to the kind of file: a command is a file where substitution demonstrably happens and this token is still left alone.

Your skill's description is the only thing that decides whether it gets used.

FRICTIONFrom binary

Everything else you write is read after that decision, if it is read at all. Two frontmatter settings can stop a skill being reachable before any of it is considered: one restricts it to loading only when the model touches files matching a pattern, so a question that touches no files quietly gets nothing; another tells the model to ask you to run the skill rather than using it. There is also a second field that looks made for describing when to reach for a skill, and it does feed the decision — but only in the terminal. The desktop listing has no handling for it at all, and other tools that read skills ignore it. So put what makes your skill findable in the description, keep it self-sufficient, and treat the other field as reinforcement you could delete without loss.

Caveat: Being selected is not the same as being read: a model can pick up a skill and still answer from what it already believes without opening the body, so anything load-bearing needs to survive a shallow read.
Caveat: The desktop finding is from a single application build, the newest available when it was checked; older builds were not examined.

If your plugin ships an MCP server, make it a stdio command with no user-configuration placeholders, and expect it to run on the user's machine rather than in the sandbox.

SILENTFrom binary Remote sandbox

In the remote sandbox the session itself does not start plugin MCP servers. The Claude Desktop app on the user's machine starts them — the same bridge it uses for servers in its own MCP configuration — and announces their tools into the session, so every call travels back to the user's machine and is gated by the Desktop app's own tool-approval prompt, not by anything in your skill or your plugin's permission rules. Only a stdio command qualifies: a server declared by URL is skipped, so an HTTP-only endpoint needs a stdio-to-HTTP shim (the mcp-remote package is the one observed working); a server whose configuration references plugin user-configuration placeholders is skipped; and one bundled Anthropic plugin is excluded from the bridge and served from inside the sandbox instead. The Desktop app must stay open for the whole session — close it and the tools vanish mid-conversation — and after a server outage the bridge's own reconnect decides when they come back, measured once at seventeen minutes.

Caveat: The plugin-server path is read from the shipped application and has not been exercised live. The same bridge's path for servers in the Desktop app's own configuration was observed live — a LAN-only device driven from a cloud session on 2026-09-18 through a stdio shim, in another project's run.
Caveat: The plugin half sits behind a server-side switch that was on at the last capture, and behind organisation policy that can disable local MCP bridging entirely or restrict it to an allowlist. Either can turn it off with no version change.
Caveat: In the local sandbox plugin MCP servers are started differently and this rule's constraints were not measured there.

Do not name a server memory in the desktop application's MCP configuration; a plugin's own memory server is safe.

Current Cowork desktop builds install their own server under the name memory, backed by the user's cloud memory. A server configured under that same name in the desktop application's MCP configuration — a common name for the reference memory server — is replaced by it, and never starts. The only trace is a line in the desktop application's log. A server that ships inside a plugin is not affected, because plugin servers are loaded under a name that includes the plugin's, so it runs next to the cloud one. Its tools are distinct from the built-in memory tools, but the model now sees two sets of memory tools; a distinctive name avoids that confusion.

Caveat: The replacement happens only when the cloud memory server is switched on, which is gated server-side (on at the 2026-09-23 capture) and requires a first-party account that is not HIPAA-restricted.
Caveat: The plugin case is established from the shipped code, not a live run.

Ship Cowork skills in a plugin, not in a project's .claude/skills folder.

A skill left in a folder's .claude/skills does not load in a local Cowork session, as far as the shipped code shows: the desktop starts the agent with user settings only, and project skills load from project settings. In a cloud Cowork session that the user grants the folder to, the desktop does upload those skills, but by default only as stubs: the model sees your frontmatter, the body is replaced by a note to run the skill on the user's device, and your scripts never run in the cloud. Past 100 skills in one folder, or 32 MiB in total, skills are dropped with little or no notice. A plugin is the only route that delivers the whole skill on both lanes.

Caveat: The cloud behaviour is controlled by a server-side mode that was set to stubs at the 2026-09-23 capture; it also supports off and full copies.
Caveat: The local-lane conclusion rests on how the desktop configures the agent, not on a live run.

Expect your plugin's local MCP server to work in Cowork, and a remote one to be replaced when the user already has that service connected.

A plugin's MCP server that runs locally over stdio gets its real tools in local Cowork: the desktop application starts it on the user's machine and hands its tools to the session. Two things replace a server with an empty stand-in that lists no tools. An organisation's MCP policy can do it to local servers and packaged extensions. And a remote server, one reached over HTTP, is replaced when the user already has the same service connected through a claude.ai connector, because the connector then serves it. Earlier desktop releases replaced every remote plugin server while that behaviour was switched on. The desktop application's log records each replacement by name, which is the quickest way to tell which case you are in.

Caveat: The remote-server replacement is switched on server-side; on one account it was observed replacing connector-covered plugin servers daily from 2026-09-15.
Caveat: Observed on one machine: a local stdio plugin server connected with its tools, and several remote plugin servers replaced by matching connectors.

Do not write exact MCP tool names for connectors or a plugin's remote servers into your skill.

In Cowork, the tools of a claude.ai connector are named after the organisation's own record for that connector, an identifier that differed between the two organisations observed, not after the service. A plugin's own server has names built from the plugin's name, but when the organisation also has the same service connected, the plugin's copy is replaced and those names disappear. A skill that spells out either form is tied to one organisation's setup. Describe the tool by what it does and let the model pick the matching tool.

Caveat: Observed on one machine across two organisations of the same user. That a hard-coded name then fails elsewhere is inferred from the names present, not from a skill run, and whether the action part of a tool's name matches between the two routes could not be checked.

When testing a plugin you also have installed, check which copy actually loaded.

A copy passed on the command line with --plugin-dir silently replaces the installed plugin of the same name — the whole plugin, skills, hooks and servers alike — and nothing tells you. The installed copy wins instead if the command-line copy is disabled, if an administrator has locked that plugin, if side-loading is turned off, or if the names differ, even only in letter case, in which case both load. The session's list of loaded plugins shows each copy's path; that path, not the plugin's source label, tells you which one you are testing.

Caveat: Cowork itself delivers every plugin this way, and sometimes lists the same plugin twice; which copy then serves a skill was not traced.

Put your skill's trigger words in its name, and keep its description short.

The list of skills the model sees has a size budget: about 30,000 characters on current models with their standard 1M context, but only 6,000 to 8,000 when the model runs a 200k context. When a user has more skills than fit, bundled skills keep their descriptions and the rest are ranked by how recently and often they were used; those that do not fit are listed by name alone. A new or rarely used skill is the first to lose its description, and a long description can lose to a shorter one even when it ranks higher. A skill whose name says what it does still gets picked.

Caveat: Most users stay under the 1M-context budget; the limit bites for users with many plugins or on 200k-context models. Users can raise it in settings.

Do not mark a hook to run for cloud sessions unless you accept that the session may have written what it runs.

When a user drives a cloud session from their own machine, the command-line tool can offer that session some of the user's hooks, run on the user's machine. By default only a hook whose script can be fingerprinted and lies outside anything the cloud session can write is offered. A hook marked to run on the device anyway can end up running a script, or a helper it loads, that the cloud session itself wrote. The user still has to consent on that machine, and a fingerprinted script that changes afterwards is refused.

Caveat: Switched off for the account checked on 2026-09-23; this describes the shipped mechanism, not a live run.

Do not conclude your hook did not fire because a local Cowork session's records do not show it.

SILENTMeasured

The agent reports hook activity in its output stream only for session-start hooks unless it is started with an option the desktop application never passes. So a local Cowork session's records show session-start hooks and nothing else, even when your other hooks ran. Check your hook's own side effects instead, or reproduce outside Cowork with hook events switched on. Cloud sessions report every hook.

Caveat: Across one machine's local session records every recorded hook event was a session-start event; that other hooks ran unseen is read from the shipped code, not observed.

Do not rely on a forked skill's exact output reaching the user.

SILENTMeasured

A skill set to run as a separate sub-task hands back only its final message, and the main conversation then writes its own answer from it — shorter, reworded, and in measured runs usually without the skill's links and caveats. Asking the main model, from inside the skill, to pass something through helps only partly and can make it warn the user about a possible prompt injection. For output whose exact wording matters, run the skill inline, which has no relay step, or write the essential content to a file the skill produces.

Caveat: Measured in 129 headless runs on one machine with one skill; behaviour differs by model — one kept links far more often than another.

What is not established

  • Plugin-root resolution differs between the two sandboxes. The plugin-root rule is written to be correct in both, at the cost of being less direct than a sandbox-specific answer would be.