Skip to content

Hammerspoon LLM script

A Hammerspoon script that calls the OpenAI API using selected text when you press a keyboard shortcut. One of my attempts to reduce the bottleneck in the AI–human interaction loop, even a little.

Differences from macOS “Writing Tools”

macOS “Writing Tools” already has similar functionality, but it has drawbacks. It does not work in WebView-based apps such as Slack, VSCode, and Obsidian, or even native apps that implement their own text controls (such as zed). Nor can I freely customize its features or prompts.

Usage

  1. Select any text.
  2. Press a shortcut (configured as desired in Hammerspoon).
  3. Enter commands.
  4. The LLM’s response goes to the clipboard.

The available commands are:

  • ko: Translate the text into Korean.
  • en: Translate the text into English.
  • cli: Write a macOS CLI command that does what the text describes.
  • regex: Express what the text describes as a regular expression.
  • sum: Briefly summarize the text.
  • explain: Explain the content of the text (code, etc.).
  • answer: Answer the content of the text.
  • enhance: Improve the text with minimal changes.
  • pr: Proofread the text.
  • bullet: Convert the text to a bulleted list.
  • dic: Explain the word or phrase.

Multiple commands are chained like a Unix pipeline. For example, summarize bullet ko summarizes the text, converts it into a bulleted list, and then translates it into Korean.

Which bottleneck does it reduce?

It can slightly reduce the bottleneck caused by human reluctance to bother or laziness.

For example, summarizing some text with AI while using the computer, translating the summary into Korean, and copying the result to the clipboard currently takes these steps:

  1. Select the text.
  2. Copy the text.
  3. Press a shortcut to open the ChatGPT input window. (If the ChatGPT app isn’t installed, switch to a browser, open a new tab, and go to the ChatGPT site.)
  4. Paste the text.
  5. Enter an additional prompt, such as “Summarize this text and translate the summary to Korean.”
  6. Copy the result.

With this script, the steps become:

  1. Select the text.
  2. Press a shortcut.
  3. Enter a prompt, such as “sum ko”.

Code

By changing api_key, endpoint, and model_id appropriately, you can use not only OpenAI but various providers compatible with the OpenAI Completion API (OpenRouter, Gemini, etc.). The code below uses Anthropic’s Haiku 4.5.

function obj:buildSystemPrompt(cmds)
    local lines = {}
    for word in cmds:gmatch("%S+") do
        local description = COMMAND_DESCRIPTIONS[word]
        if description then
            table.insert(lines, "- " .. description)
        end
    end
    return string.format(SYSTEM_PROMPT_BASE, table.concat(lines, "\n"))
end

function obj:init()
    return self
end

function obj:setStatus(emoji)
    self.statusIcon = hs.menubar.new()
    self.statusIcon:setTitle(emoji)
end

function obj:clearStatus()
    self.statusIcon:delete()
    self.statusIcon = nil
end

function obj:bindHotkeys(mapping)
    local spec = {
        generateText = hs.fnutils.partial(self.generateText, self),
    }
    hs.spoons.bindHotkeysToSpec(spec, mapping)
    return self
end

function obj:generateText()
    hs.eventtap.keyStroke({ "cmd" }, "c")
    hs.timer.usleep(0.05)

    local focusedApp = hs.window.frontmostWindow():application()
    local body, cmds = self:getBodyAndCmds()
    hs.timer.doAfter(0.01, function() focusedApp:activate() end)

    if not body then
        return
    end

    local header = {
        ["Content-Type"] = "application/json",
        ["Authorization"] = "Bearer " .. self.api_key,
    }
    local reqBody = {
        model = self.model_id,
        messages = {
            { role = "system", content = self:buildSystemPrompt(cmds) },
            { role = "user",   content = body .. '\n\n' .. cmds },
        },
        max_completion_tokens = 10240,
    }

    self:setStatus("⏳")
    hs.http.asyncPost(
        self.endpoint,
        hs.json.encode(reqBody),
        header,
        function(http_code, res)
            self:clearStatus()
            local resBody = hs.json.decode(res)
            if http_code ~= 200 then
                hs.alert("Failed to get response: " .. resBody.error.message)
                return
            end

            local resText = resBody.choices[1].message.content
            hs.pasteboard.setContents(resText)
            hs.alert("Updated clipboard")
        end
    )
end

function obj:getBodyAndCmds()
    local body = hs.pasteboard.getContents()
    if not body then
        hs.alert("Please select the text first")
        return nil
    end

    local button, cmds = hs.dialog.textPrompt("Enter the command", "", "ko summarize", "Ok", "Cancel")
    if button == "Cancel" or not cmds or cmds == "" then
        return nil
    end

    for word in cmds:gmatch("%S+") do
        if not COMMAND_DESCRIPTIONS[word] then
            return nil
        end
    end

    return body, cmds
end

return obj
Created
Updated

Machine-translated from the Korean original.

2026 © ak | markdown | 한국어