Emacs AI Setup

Published:
Last updated:
Keywords: emacs

This guide describes most of the AI tools that I use with Emacs and how they fit together.

A high-level overview:

  • I pare down and simplify minuet’s functionality quite a bit, and control which files and kinds of buffers it can act on.
  • I tend to use gptel as the source of truth for which providers and models to use, as well as any common arguments like temperature or amount of thinking. It’s also helpful for one-off Q&A sessions.
  • I tend to use OpenCode outside of Emacs for most AI work, and then rely on auto-revert-mode (as implicitly enabled by Magit) to have Emacs catch up and review the changes.
  • It’s not Emacs-related, but I also like t3.chat for web research tasks and image generation.

Contents

Inline completions with minuet

minuet provides automatic inline AI auto-completions in Emacs. I like that Minuet includes just the right amount of buffer context with the completions API, which improves the quality of the results.

By default, it shows multiple suggestions that you can cycle through, but I prefer the Cursor-style approach of showing just one suggestion at a time. This keeps the experience very simple and low-friction.

I’ll explain how to achieve that over the next few sections.

Basic configuration

After installing minuet and gptel from MELPA, I configure Minuet like so:

(with-eval-after-load "minuet"
  (setopt minuet-add-single-line-entry nil
          minuet-auto-suggestion-debounce-delay 0.3
          minuet-n-completions 1)

  (keymap-set minuet-active-mode-map "C-c C-c" #'minuet-accept-suggestion)
  (keymap-set minuet-active-mode-map "C-c C-n" #'minuet-next-suggestion)
  (keymap-set minuet-active-mode-map "C-c C-p" #'minuet-previous-suggestion)
  (keymap-set minuet-active-mode-map "C-g" #'minuet-dismiss-suggestion)
  (keymap-set minuet-active-mode-map "<tab>" #'minuet-accept-suggestion-line))

The settings are:

  • minuet-add-single-line-entry: Don’t clutter the completion menu too much with extra single-line versions of the suggestions
  • minuet-auto-suggestion-debounce-delay: How long to wait before requesting a suggestion
  • minuet-n-completions: Set to 1 for Cursor-style single suggestions

Once a code complete suggestion is presented, the following keys are available, in order of importance:

  • TAB: Complete just the first line of the suggestion
  • C-c C-c: Insert the entire multi-line suggestion
  • C-g: Remove the suggestion
  • C-n and C-p: Cycle through the next or previous suggestions

I’ll now describe a few other customizations that I think make Minuet a bit more ergonomic.

Blocking some suggestions within buffers

I make sure that suggestions only happen at the end of non-empty lines, to avoid having them trigger in cases where I’m reading through the file quickly or editing existing code. It’s also important to not attempt suggestions when the buffer is read-only, since that will never be helpful.

(defun my-minuet-block-suggestions ()
  "Return nil if we should show suggestions, t (blocked) otherwise.

Criteria:
- File must be writable
- Cursor must not be at beginning of line
- Cursor must be at the end of line (ignoring whitespace)."
  (not (and (not buffer-read-only)
            (not (bolp))
            (looking-at-p "\s*$"))))

(with-eval-after-load "minuet"
  (add-hook 'minuet-auto-suggestion-block-functions
            #'my-minuet-block-suggestions -100))

Enabling Minuet at file-level

I enable Minuet’s auto-suggestion mode in programming buffers, with some exclusions:

;; kill switch for minuet functionality, currently allowed:
(defvar my-minuet-auto-suggest-p t)
;; my shared Emacs settings have this (disabled on every file by default):
(defvar my-minuet-exclude-file-regexps '(".*"))
;; my personal configuration has something like this:
(setq my-minuet-exclude-file-regexps '(".emacs.d/" ".gnupg/" ".ssh/"))

(defun my-minuet-exclude ()
  (let* ((filename (buffer-file-name)))
    (or (not filename)
        (when-let* ((lst my-minuet-exclude-file-regexps))
          (string-match-p
           (string-join (mapcar (##concat "\\(?:" % "\\)") lst) "\\|")
           filename)))))

(defun my-minuet-maybe-turn-on-auto-suggest ()
  (when (and my-minuet-auto-suggest-p (not (my-minuet-exclude)))
    (minuet-auto-suggestion-mode 1)))

(add-hook 'prog-mode-hook #'my-minuet-maybe-turn-on-auto-suggest t)

You’ll want to customize my-minuet-exclude-file-regexps to make sure any other sensitive directories are excluded from having their buffers sent to AI.

Provider configuration

I sync minuet’s provider settings from my gptel configuration, which lets me use the same API keys and model settings across both tools. This part is a bit complex, but I switch between models often enough that it ended up being useful to have.

(defvar my-gptel-backend 'my-gptel--opencode-zen)
(defvar my-gptel-model   'claude-opus-4-5)
(defvar my-gptel-preferred-provider 'my-gptel--opencode-zen)
(defvar my-minuet-provider 'openai-compatible)
(defvar my-minuet-model    'kimi-k2)

(defvar my-gptel--backends-defined nil)
(defvar my-gptel--claude nil) ; not defined here
(defvar my-gptel--openai nil) ; not defined here
(defvar my-gptel--opencode-zen nil)

(defun my-gptel-ensure-backends ()
  (unless my-gptel--backends-defined
    (setq my-gptel--backends-defined t)

    (setq my-gptel--opencode-zen
          (gptel-make-openai "OpenCode Zen"
            :host "opencode.ai"
            :endpoint "/zen/v1/chat/completions"
            :stream t
            :key #'gptel-api-key-from-auth-source
            :models '((claude-opus-4-5
                       :description "Claude Opus 4.5 model"
                       :capabilities (tool-use json media)
                       :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp")
                       :context-window 200
                       :input-cost 15.0
                       :output-cost 75.0
                       :cutoff-date "2025-02")
                      (kimi-k2
                       :description "Kimi K2 model"
                       :capabilities (tool-use json)
                       :context-window 200
                       :input-cost 0.6
                       :output-cost 2.5)))))

  (setq gptel-backend (symbol-value my-gptel-backend))
  (setopt gptel-model (or my-gptel-model (car (gptel-backend-models gptel-backend)))
          gptel-expert-commands t
          gptel-rewrite-default-action 'accept
          gptel-temperature my-gptel-temperature))

(with-eval-after-load "gptel"
  (my-gptel-ensure-backends))

(defun my-auth-source-get-api-key (host &optional user)
  (if-let* ((secret (plist-get
                     (car (auth-source-search
                           :host host
                           :user (or user "apikey")
                           :require '(:secret)))
                     :secret)))
      (if (functionp secret)
          (encode-coding-string (funcall secret) 'utf-8)
        secret)
    (user-error "No `apikey' found in the auth source")))

(defun my-minuet-get-api-key (backend)
  (my-auth-source-get-api-key (gptel-backend-host backend)))

(defun my-minuet-sync-options-from-gptel (m-backend g-backend &optional g-model)
  "Synchronize Minuet provider options from the current gptel backend."
  (let* ((options-name (format "minuet-%s-options" (symbol-name m-backend)))
         (options (symbol-value (intern options-name)))
         (model (or g-model (car (gptel-backend-models g-backend)))))
    (when (memq m-backend '(openai-compatible openai-fim-compatible))
      (plist-put options :name (gptel-backend-name g-backend))
      (setf (plist-get options :optional)
            (copy-sequence (gptel--model-request-params gptel-model)))
      (plist-put options :model (symbol-name model)))
    (plist-put options :api-key
               `(lambda () (my-minuet-get-api-key ,g-backend)))
    (cond ((eq m-backend 'openai-fim-compatible)
           (plist-put options :end-point
                      (format "%s://%s%s"
                              (gptel-backend-protocol g-backend)
                              (gptel-backend-host g-backend)
                              "/v1/completions")))
          ((eq m-backend 'openai-compatible)
           (plist-put options :end-point
                      (format "%s://%s%s"
                              (gptel-backend-protocol g-backend)
                              (gptel-backend-host g-backend)
                              (gptel-backend-endpoint g-backend)))))))

(defun my-minuet-init-provider ()
  "Initialize Minuet provider settings based on current gptel backend."
  (interactive)
  (my-gptel-ensure-backends)
  (let* ((g-provider (pcase my-minuet-provider
                       ('claude my-gptel--claude)
                       ('openai my-gptel--openai)
                       ((or 'openai-compatible 'openai-compatible-fim)
                        (symbol-value my-gptel-preferred-provider)))))
    (my-minuet-sync-options-from-gptel my-minuet-provider
                                       g-provider
                                       my-minuet-model)
    (setq minuet-provider my-minuet-provider)))

(with-eval-after-load "minuet"
  (my-minuet-init-provider))

This approach means I only need to configure API keys once in my auth-source file, and both gptel and minuet can use them. There’s a bit more to my gptel configuration that you can find in my shared Emacs settings repo.

I try to lazy-load libraries like minuet and gptel to the maximum extent possible so that Emacs starts quickly. This kind of setup with my-gptel-ensure-backend lets me initialize them correctly whenever I begin to need either library.

For AI models within Emacs:

  • I currently use Kimi K2 for Minuet suggestions.
  • I use Claude Opus 4.5 for one-off gptel prompts and gptel-fn-complete.

I currently use OpenCode Zen exclusively, for several reasons:

  • They have a tasteful selection of models.
  • It’s easy to switch between different models with the same API credit pool, which can be configured to automatically reload when below a threshold.
  • When they run a model, it’s almost certain to be stable, unlike my prior experience with some Openrouter providers.
  • They have models that have been no-cost for a while, and they recently started offering GPT 5.1 (one of my primary writing, coding, and task-following models) at a lower price than OpenAI does.

Keybinds

I have the following other keybinds to manually trigger minuet or gptel-fn-complete, in addition to some other useful xref bindings:

(defvar my-xref-map
  (let ((map (make-sparse-keymap)))
    (keymap-set map "c" #'minuet-show-suggestion)
    (keymap-set map "f" #'gptel-fn-complete)
    (keymap-set map "." #'xref-find-definitions)
    (keymap-set map "," #'xref-go-back)
    (keymap-set map "/" #'xref-find-references)
    (keymap-set map "RET" #'embark-act)
    map)
  "My key customizations for AI and xref.")

(keymap-global-set "C-c ." my-xref-map)
(keymap-global-set "C-c C-." my-xref-map)
(keymap-global-set "C-x ." my-xref-map)
(keymap-global-set "C-x C-." my-xref-map)

Since these get called somewhat frequently, I like to bind a few adjacent keys to the same thing, in case I miss a keypress or hold down Ctrl for too long.

Completing functions with gptel-fn-complete

Earlier in the year I wrote gptel-fn-complete. It uses gptel under the hood, and is helpful for completing entire functions or regions. It makes a reasonable attempt to automatically find the bounds of the function at point, and if you’re using a treesit-compatible mode, it will always succeed at that. This was nice to pair with local AI, since the context sent to the AI did not have to be very large.

Now that the tooling and remote AI models have improved to such a large degree, I find myself using OpenCode almost exclusively instead.

Agentic workflows with OpenCode

I use OpenCode for larger tasks that benefit from an agentic approach, such as exploring codebases, making multi-file changes, or debugging complex issues.

OpenCode runs in the terminal and has access to tools for reading files, searching code, and making edits. I typically run it in a Ghostty terminal rather than inside Emacs; if I change my mind about that, I’d likely pick up agent-shell to manage OpenCode.

One helpful (and perhaps unexpected) side effect of using Magit is that it has an automatically activated major mode named magit-auto-revert-mode that automatically turns on auto-revert-mode when an open Emacs buffer is tracked using Git.

In practice this means that if OpenCode is making changes, Emacs will pick those up and refresh the buffer contents automatically. This ends up being a great workflow for me, since I can review in Emacs what the AI model did, without having to bring in any specialized Emacs modes to help. I especially don’t want to review diffs in any tool other than Magit.

Wrapping up

This three-layer approach covers my AI needs well:

  1. minuet for seamless inline completions while typing
  2. gptel-fn-complete for quick, targeted code actions at point
  3. OpenCode for almost every kind of other task

In case any of the above doesn’t work as-is (which is quite possible), it may also be helpful to refer to my shared Emacs settings repo.