← Keyhac crftwr/keyhac on GitHub · craftware

AI Integration

Give this page’s URL to your AI agent

Ask it to set up Keyhac’s skill and MCP server, and it can do most of it from here — it will read Setting it up below, which is written for it, and ask you for the parts only you can do. You do not have to follow these instructions yourself.

Keyhac’s tray menu has the link: AI Integration → Setup Guide, pinned to the version you are running.

Keyhac can expose its tools over MCP, so you can ask for what you want in a chat window and have the agent carry it out against the running Keyhac. Two kinds of thing: changing what a key does — it reads your key tables and your config.py, writes the change back, and reloads it — and writing an action, where it inspects the actual screen, writes the code, runs it, reads the error, and fixes it, instead of guessing at selectors and handing you code to debug.

What it leaves behind is plain Python in your own files. No model runs when you later press the key.

MCP is an open protocol and nothing here is specific to one vendor. Every client connects the same way, through the keyhac-mcp-bridge command — as a stdio MCP server where the client can register one, or run directly from a shell where it cannot. Anything that can do either should work; which clients have been tried is a shorter list.

This is off unless you turn it on, and it is worth understanding why before you do: the endpoint reads the accessibility tree of every application you have open and can run the actions you register. See Security.

Upgrading from 2.2.0 or 2.2.1

Two calls were removed while this feature was being built. A config.py that still makes either fails to load — loudly, with your previous configuration staying active until you fix it.

import open_issues
kt["Fn-I"] = open_issues.OpenIssues()      # this is all it ever needed

Turning it on

Tick AI Integration: MCP Server in the console window, or AI Integration → MCP Server in the tray / menu bar menu. The console logs the port it chose, and then one line for every call the agent makes — which tool, with which arguments, and how big an answer went back:

INFO [keyhac.MCP] describe_screen(app='Chrome') -> 4812 chars
INFO [keyhac.MCP] write_extension(name='translate_clipboard', source='import ...') -> 118 chars

Set the console’s level to Debug for the whole of each request and reply rather than a summary. The envelope stays on one line and the payload is printed underneath as itself, so a screen dump reads as the tree it is:

DEBUG [keyhac.MCP] <- {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"<result.content[0].text>"}],"isError":false}}
AXWindow 'my-projects (Workspace)'
  AXGroup 'my-projects (Workspace)'
    AXWebArea 'my-projects (Workspace)'
      AXStaticText = 'Diff editor'

It turns itself off after 60 minutes, and it is not remembered across restarts. Tick it again when you need it — that is the intended rhythm, not an inconvenience to work around.

The reason is what the feature is: an agent helps you write an action, and the action then runs with no model involved. An endpoint still listening the next morning is serving nothing — while still able to read every window you have open, which is the largest thing it can do. A switch you have to remember to turn off is one you will not.

The timeout is fixed from when you tick it and is not extended by use, so whatever is driving the endpoint cannot hold its own permission open by working periodically.

There is deliberately no configuration API for this. An endpoint that reads every window, and that can write and run action code, should be visibly on or visibly off; a line in the middle of a several-hundred-line config.py tells you what was asked for once, and nothing about what is true now. The same reasoning already governs the keyboard hook, which has always been a checkbox rather than a setting. (2.2.0 had a keymap.enable_mcp_server() call for this. It is gone — delete the line from your config.py and use the switch.)

What being on lets the agent do

Beyond reading your screens:

An action is a ThreadedAction subclass in a file under extensions/, and the agent can list and start every one of them. No registration, no separate category, nothing to keep in step.

config.py is written whole, never patched, so the agent is told to read it first and change as little as it can. Keyhac keeps the version it replaced as a .bak-<timestamp> beside it and logs every write to the console with a +N/-M line count — a rewrite you did not want is visible and undoable, not silent.

Listing does not run anything. Keyhac finds those classes by parsing the files, never by importing them, so a directory of half-finished experiments stays inert until something names one. That is the property extensions/ has always had: a module your config.py does not import does not execute.

The key binding is still yours — you no longer have to type it. While the endpoint is open, an action class is runnable from a chat window and bound to nothing. What config.py adds is a key of your choosing that goes on working with nothing connected at all, and either of you can write that line: ask for the two lines and paste them, or ask for the binding and let the agent put it there. The key is the one you named, in your file, either way.

Setting it up

This section is addressed to the AI agent. If you are a person reading it, you can follow it too — but handing the URL over is the shorter path.

There are two installs, and neither implies the other. Getting one without the other is the common failure, and the halves fail very differently: with the skill and no connection, you will correctly say you cannot see the user’s screen, which looks like the feature being broken. With the connection and no skill, everything appears to work and you will write actions containing sleep and screen coordinates, which break on a different machine.

How your host installs an MCP server or a skill is your knowledge, and this page mostly does not guess at it. What follows is what Keyhac knows and you cannot — plus, where a host is common enough to be worth naming, what is true for it today: Claude Desktop’s config file is in The bridge, for stdio-only clients, and Claude’s skill uploader in Add the skills. Both sections are below this one, and both are for you as much as for the user.

1. The skill

There are two, and which you need depends on what the user asked for:

Most people want the first. Take both if you do not know yet. They are on the release matching the running version — the tag is the segment after blob/ in the URL you fetched this page from, and it already carries its own v:

https://github.com/crftwr/keyhac/releases/tag/<tag>

Install them however this host takes a skill — but expect that to be a step only the user can take. On most hosts a skill arrives by uploading a file through a settings UI, with no tool behind it, so do not spend the round trying to do it yourself. Give them the link and the steps, say what it buys, and go on with what you can do. Add the skills has the path for Claude, including the setting that has to be on first.

Unless this host keeps its skills as files — then it is yours to do, and you should simply do it rather than handing back instructions. Claude Code reads ~/.claude/skills/<name>/SKILL.md, so unzipping each bundle into a directory of its own under there is the entire install. The test is not what the host is called but whether you can write where it looks, so check before assuming the upload path.

Each bundle documents that version’s API, so a mismatch is worth mentioning to the user rather than working around.

2. The connection

Ask the user to switch the endpoint on first: AI Integration → MCP Server in Keyhac’s tray menu, or the AI Integration: MCP Server checkbox in its console window. It is off by default and you cannot turn it on yourself.

Then settle how this host runs the bridge, which is the one thing that varies. A client that can launch an MCP server as a child process registers keyhac-mcp-bridge as a stdio server, and Claude Desktop is that kind — so if that is where you are, the bridge is the thing to configure. A client that cannot register a server at all, but can run commands, runs the same bridge a call at a time.

3. How to go about it

Which clients have been tried

Client Transport Status
Claude Desktop stdio → the bridge Verified on macOS and Windows — the actions in examples/actions/ were authored through it, and one authored on macOS then ran unchanged on Windows
Claude Code stdio → the bridge, registered with claude mcp add Verified on macOS — set up from this page’s URL and nothing else, and the tools answer inside a conversation
VS Code Copilot stdio → the bridge via .vscode/mcp.json Verified on macOS — tools discoverable and usable in chat; no UI needed beyond JSON config file in workspace
A client with a shell but no MCP transport the bridge, one call at a time Mechanics verified on macOS--tools and a tools/call both answer from a shell. No client has been driven through a whole task this way
Anything else with MCP support the bridge, as a stdio server Should work, untried

“Untried” is not scepticism about those clients — nobody has run them against this endpoint yet. If you do, whether it worked or not is the useful report.

What was verified on Claude Code: handing it this page’s URL was the whole of the setup. It installed both skills into ~/.claude/skills/ itself — no upload, no user step — registered the bridge at user scope, and connected. A later session then called the tools as native tools and got real answers back. Note that the second half needed a new session: the one that did the setup was older than the server it had just registered, which is the ordinary case and the reason the last step of How to go about it says to verify in a fresh one.

Connecting to the HTTP port directly is out of scope. The daemon does serve loopback HTTP with a bearer token — that is what the bridge talks to, and Security describes it — but it is not a setup this page supports or has tried. Everything a client needs, the bridge already does: it finds the port and token itself on every request, so a Keyhac restart on a new port needs no change, and the token never has to be handed to the client or repeated into a conversation. A client that can run a command can use it, whether or not it can register an MCP server — see A client with a shell but no MCP transport.

The bridge, for stdio-only clients

Claude Desktop starts a local MCP server as a child process and talks JSON-RPC over its stdin/stdout. Keyhac cannot be that child — it is a resident daemon holding the keyboard hook and your focus history, and a second copy per conversation would be a second hook and a second accessibility prompt. So keyhac-mcp-bridge runs as the child instead and forwards to the daemon already running. It holds no tool definitions and no logic, so the two cannot drift apart.

Register it — Settings → Developer → Edit Config, or ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "keyhac": { "command": "keyhac-mcp-bridge" }
  }
}

Restart Claude Desktop — fully quit it (⌘Q); closing the window does not reload the config.

An absolute path is usually required, because GUI apps do not inherit a shell PATH.

The path is published as bridge in ~/.keyhac/mcp.json, so read it from there rather than transcribing it — and the Keyhac console prints it at startup on the line after “MCP server listening”. Failing both, it depends on how Keyhac was installed:

Install Path
macOS app bundle /Applications/Keyhac.app/Contents/Resources/bin/keyhac-mcp-bridge
Windows zip / portable bundle keyhac-mcp-bridge.exe, beside Keyhac.exe
Windows Microsoft Store install %LOCALAPPDATA%\Microsoft\WindowsApps\keyhac-mcp-bridge.exe — see below
pip install keyhac which keyhac-mcp-bridge, or the virtualenv’s bin/
Source checkout .venv/bin/keyhac-mcp-bridge, created by make install — note it is not on PATH when Keyhac is started with make run
{
  "mcpServers": {
    "keyhac": {
      "command": "/Applications/Keyhac.app/Contents/Resources/bin/keyhac-mcp-bridge"
    }
  }
}

On the Microsoft Store install, do not point a client inside C:\Program Files\WindowsApps. Nothing in there can be started by a program that is not part of the package — Windows answers “Access is denied” no matter how the path is spelled, and the file is perfectly readable the whole time, which makes it look like a Keyhac problem rather than a Windows one. What you get is an MCP server that disappears the moment the client starts it, with nothing in its log. Use the command Windows registers for the package instead:

{
  "mcpServers": {
    "keyhac": {
      "command": "C:\\Users\\<you>\\AppData\\Local\\Microsoft\\WindowsApps\\keyhac-mcp-bridge.exe"
    }
  }
}

That directory is on PATH, so a client that inherits one can just say keyhac-mcp-bridge. Either way, ~/.keyhac/mcp.json publishes the exact path for the install you are running — read it from there rather than transcribing this. (Keyhac 2.2.2 and earlier published a path inside the package here, which could not work; the fix is in 2.2.3.)

Older Windows bundles shipped keyhac-mcp-bridge.cmd rather than the .exe. It is still there and still works — it now just forwards — so a config written against 2.2.0–2.2.2 needs no change.

The bridge does not have to come from the same install as the daemon — it reads the endpoint file and forwards.

Whichever client you register it in, restart the client after editing the JSON. An MCP config file is read when the client starts the server, not when the file changes, so a client left running keeps the server list it had at launch — no keyhac tools, no error, nothing in a log to explain it. Some clients offer a narrower reload (a Restart on the server entry, or a window reload); either works. Restarting the whole client always does.

VS Code Copilot integration

VS Code Copilot uses .vscode/mcp.json (workspace) or ~/.copilot/mcp-config.json (user) to configure MCP servers. The same bridge mechanism works here.

Workspace configuration (checked into source control):

{
  "servers": {
    "keyhac": {
      "type": "stdio",
      "command": "/Applications/Keyhac.app/Contents/Resources/bin/keyhac-mcp-bridge"
    }
  }
}

Place this in .vscode/mcp.json at the root of your project. The bridge path will depend on your Keyhac installation:

Install Path
macOS app bundle /Applications/Keyhac.app/Contents/Resources/bin/keyhac-mcp-bridge
Windows (portable or zip) C:\path\to\keyhac-mcp-bridge.exe
Microsoft Store C:\Users\<you>\AppData\Local\Microsoft\WindowsApps\keyhac-mcp-bridge.exe
Linux (pip) /path/to/venv/bin/keyhac-mcp-bridge or just keyhac-mcp-bridge

User configuration (for all projects): For user-level setup that applies across all VS Code projects, place the same JSON in ~/.copilot/mcp-config.json. This survives workspace changes and restarts.

Then reload VS Code. As with any client, the file is read when the server is started, so a window that was already open will not list the Keyhac tools until you restart the server from VS Code’s MCP server list — or, failing that, reload the window or restart VS Code itself.

The bridge earns its place here for the same reason it does in Claude Desktop: it reads ~/.keyhac/mcp.json on each request, so the configuration keeps working when Keyhac restarts and picks a new port. That is the whole reason the HTTP port is not a setup this page supports — anything that pins today’s port goes stale the next time Keyhac starts.

A client with a shell but no MCP transport

Some agents cannot register an MCP server at all but can run commands. The bridge works for them too, one call at a time, because the daemon keeps no session: each request is answered on its own, so there is no initialize handshake to perform first. One JSON-RPC line in, one line out.

Ask what the tools are rather than working from memory of this page:

keyhac-mcp-bridge --tools

That prints the daemon’s own tool list as JSON — names, descriptions and argument schemas, about 10KB for the eighteen tools. It is the same list the daemon serves an MCP client, fetched live rather than copied, so it cannot describe a tool that is not there. It needs Keyhac running with AI Integration > MCP Server on; without that it explains itself on stderr and exits nonzero, leaving stdout empty for whatever is parsing it.

To call one, write the request to a file and redirect it in:

cat > /tmp/keyhac-request.json <<'JSON'
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_windows","arguments":{}}}
JSON
keyhac-mcp-bridge < /tmp/keyhac-request.json

Inlining the JSON in an echo works right up until an argument carries a quote, a backslash or a non-ASCII character — which is most window titles and half the paths — and then it fails as a shell quoting error that reads like a Keyhac one. A file has no quoting rules.

Keep each request on one line. The transport is newline-delimited, so a request pretty-printed across several lines is several requests, none of them valid JSON — and the failure is silent: no output, exit status 0, as though the tool had nothing to say. Pass separators or -c to whatever writes the JSON. Several one-line requests in a file are fine, and come back as several replies in order.

Use the bridge even though a shell has curl. Driving the HTTP port directly is out of scope, and this is the case that shows why: curl means reading mcp.json, parsing the token out, and then carrying it — into the conversation, into shell history, into anything that logs commands. The token is what stands between any local process and your screen contents. Through the bridge it never leaves the machine’s filesystem, and a Keyhac restart on a new port needs no change to anything.

Use a real MCP registration when the client supports one. This path costs you the consent surface: as native tools, a client can prompt for write_config, write_extension, delete_extension and start_action individually, which is the whole design of the switch these tools sit behind. Run through a shell, all of them are just a command, and a client that gates tools individually can no longer tell reading your screen from rewriting your configuration.

Add the skills

The tools tell the agent what your screen contains and what your configuration holds. The skills tell it what to do with that — and without them you get plausible-looking work that fails later: actions full of sleep and screen coordinates, or a key binding written into a table that never activates.

There are two, and they overlap in nothing:

  For
keyhac-key-table-configuration-skill.zip changing what keys do — remapping, per-application tables, one-shot and user modifiers, editing config.py
keyhac-action-authoring-skill.zip writing an action — Python that drives another application’s UI, for systems with no API

Most people want the first. Take both if you are not sure.

Get them from the release:

https://github.com/crftwr/keyhac/releases/tag/<tag>

<tag> is v followed by the version Keyhac’s console prints at startup — v2.2.1, for instance. Match the version you are running: that release is the one whose API the skills describe. Each bundle carries the version stamped inside, so a mismatch is visible after the fact rather than silent.

The bundle is shaped for Claude’s skill uploader, whose two requirements it meets — a SKILL.md at the archive root, carrying its name and description as YAML frontmatter. In Claude today that is Customize → Skills → + → Create skill → Upload a skill, then pick the zip. Menus move between releases; if that path is not what you see, look for Skills in the settings rather than trusting this line.

Turn on “Code execution and file creation” first — Settings → Capabilities, or for a Team or Enterprise plan an administrator enables it for the organization. Skills run on it, and while it is off they will not appear at all, which looks like a plan restriction and is not one. This is the usual reason an upload seems to go nowhere. On an Enterprise organization with skill scanning enabled, an uploaded skill is also scanned before it can run — a minute or two.

The content is not Claude-specific. Unzipped it is Markdown, and any agent that can be handed documents can be handed these. What that does not buy is the habit of consulting them unprompted, which is the part a skill mechanism provides.

(Building them from a source checkout, if you are working on Keyhac itself: make skill-bundle writes the same zips into dist/.)

A skill is not the connection, and neither step implies the other. A skill is knowledge — rules and an API reference, with no way to reach your machine; the tools come from the bridge registered above. Upload only a skill and the agent will correctly tell you it cannot see your windows. Connect only the tools and it will see them, then write actions that use sleep and screen coordinates, or bindings it never checked.

What the agent can do

Tool  
list_windows, get_focus what is open, what is focused
describe_screen a window’s element tree as indented text
find_elements targeted search by role / name / identifier / text, each match placed by its ancestor path
read_text an element’s whole text — terminal scrollback, editor buffer
enable_content_access make a Chromium/Electron app expose its content (macOS)
describe_keymap the key tables, which match the current focus, and what each binds
read_config, write_config your config.py — read it, replace it (backup kept)
list_actions the action classes in extensions/, what is running, how each last ended
start_action, get_action_result, cancel_action start an action, collect what it logged, stop it
list_extensions the files in extensions/, including helpers with no action class
read_extension read a module as it is on disk
write_extension save a module — the whole file
delete_extension retire a module — renamed to a backup beside it, not erased
reload_config re-read your config.py after you edit it, and report any error

None of these has a permission of its own, and that is the design rather than an omission: the endpoint being open is the permission, and it is open only while you have ticked the switch, for an hour. There is no list of names to audit and nothing half-enabled — the switch being off is the answer.

Your config.py decides which action classes get a key. It no longer decides which the agent may run; the switch does, and the switch expires.

The loop

This is the loop for writing an action, which is the long one. Changing what a key does is shorter: ask for it, and the agent reads your config.py and your key tables, writes the change back, reloads it, reports what the reload said, and asks you to press the key. Nothing has to be discovered from the screen, so there is no round trip through it.

  1. Open the screen the action will work against.
  2. Ask for what you want, in your own words. Naming the application and the output is useful; naming an API is not — if you find yourself typing find_element, tell us, because that means the skill is failing.
  3. The agent reads the screen, writes the action, and saves it into extensions/. It runs it, reads its own failure, fixes it, and runs it again. You are not in this part, however many rounds it takes.
  4. When it works, it gets a key. Name the key and the agent writes the two lines into config.py — an import and the binding — then reloads the file and tells you what the reload said. If you would rather do it yourself, ask for the lines instead; it is the same sentence either way. Then press the key: whether it does the right thing is the one thing nobody else can check for you.

Step 4 is the only part that outlives the hour. Everything before it is reachable only from a chat window and only while the endpoint is open, and stops mattering when it closes; the binding keeps working with nothing connected at all, which is the point of putting it in your file rather than leaving it in a conversation. So it is the step worth looking at — and Keyhac makes that possible rather than asking you to trust it: the file it replaced is kept beside it, and the write is on the console with its line count.

Restart Keyhac after upgrading it. reload_config reloads your config.py, not Keyhac’s own modules — a new version of the tools is only picked up by a restart. (Found the hard way: two live checks of a change to the tools were really testing the build the daemon started with.)

Working from a recorded demonstration

Claude Desktop can record a task — screen, clicks, typing, and voice — and turn it into a skill. That skill is a good input to an action, with one division of labour that matters:

So the shape is: record the task, then ask Claude to turn that skill into a Keyhac action against the screen in front of it. Expect it to ask questions first — a recording shows you set a filter to “active” but not whether that is a constant or an argument.

The recording is Anthropic’s, not Keyhac’s: it is captured by Claude Desktop under its own consent dialog and sent to Claude. Keyhac never records your keyboard, and does not read the recording. Its warning applies — don’t type passwords or show private material while recording.

Security

What this does not protect you from

Worth knowing before you tick it.

describe_screen and read_text put the contents of your windows — including web pages — into the model’s context. That text is untrusted: a page can contain something written to be read by an agent rather than by you. The authoring skill tells the agent that screen content is data and never an instruction, and that is a real mitigation rather than a complete one.

So state it plainly: while the endpoint is open, code you have not read can be written into extensions/ and run. That is what the switch grants — it is the feature, not a gap in it. Registering an action in config.py used to be a human step between “the agent wrote code” and “the code ran”; it is not any more, for as long as the endpoint is open.

And config.py is writable too, which is the one write meant to outlast the hour — so a change there is the one to actually read afterwards.

What still holds: a write lands in one of exactly two places, extensions/ under a module name or config.py, and neither is silent — the previous version is kept beside it and the write is logged with its line count. Nothing in extensions/ executes unless it is named. And when the hour is up it all stops, including the screen reading; what survives is your config.py, doing what it says it does.

The practical answer is therefore built in rather than left to you: the exposure coincides with you sitting in front of the screen, watching a console that reports every call and not only every write, and it ends by itself. Which is worth more than a gate nobody reads.