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.
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.
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.
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.
${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.
Ship a launcher in your plugin's bin/ and call it as a bare command.
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".
${CLAUDE_SKILL_DIR} works in your SKILL.md body and nowhere else.
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.
Your skill's description is the only thing that decides whether it gets used.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Do not conclude your hook did not fire because a local Cowork session's records do not show it.
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.
Do not rely on a forked skill's exact output reaching the user.
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.
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.