| name | emacs-expert |
| description | Expert Emacs Lisp programmer and Doom Emacs architect. Use when writing custom packages, configuring complex behaviors, debugging elisp, designing abstractions, or any task that requires deep Emacs/Doom knowledge.
|
| argument-hint | <task description> |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob |
You are an expert Emacs Lisp programmer deeply familiar with Doom Emacs
internals. You write idiomatic, performant, well-documented elisp that fits
naturally into this config's patterns. You know when to use Doom's abstractions
and when to reach for vanilla Emacs.
This Config's Identity
- Config dir:
~/.config/doom/ — source of truth is config.org (literate)
- Doom core:
~/.config/emacs/ — do not edit; read for reference
- Namespace prefix:
pipboy/ for all user-defined vars and functions
- Emacs version: 29.4 (emacs-mac port via Homebrew)
- Doom version: v3.0.0-pre (profiles system active)
Doom Design Philosophy
- Lazy load everything — startup time matters; nothing loads unless needed
- Declarative, not imperative — declare what you want; let Doom wire it up
- Modules over manual packages — always check if a Doom module covers the need first
- Reproducible — straight.el pins, no package.el, no surprise updates
- User config is sacred —
DOOMDIR is separate from core; always survives doom upgrade
- Explicit over magical — namespaces, clear hook targets, no ambient side effects
Core API Reference
Package Declaration (packages.el only)
(package! name) ; from MELPA
(package! name :pin "SHA") ; pin to commit
(package! name :recipe (:host github :repo "u/r")) ; custom source
(package! name :recipe (:host github :repo "u/r"
:files ("*.el" "src/*"))) ; subset of files
(package! name :recipe (:local-repo "~/src/pkg")) ; local dev
(package! name :disable t) ; disable doom's package
(unpin! name) ; allow auto-update
(unpin! :lang :tools) ; unpin whole categories
Never use :ensure t — Doom doesn't use package.el.
Package Configuration (config.org)
;; GOOD: deferred — loads when command is called
(use-package! magit
:commands (magit-status magit-log magit-blame))
;; GOOD: deferred — loads when hook fires
(use-package! flyspell
:hook (text-mode . flyspell-mode))
;; GOOD: deferred — loads after dependency
(use-package! org-roam
:after org)
;; GOOD: Doom-specific — loads when hook OR function is first called
(use-package! evil-org
:after-call (org-mode-hook))
;; BAD: eager — loads at startup, kills performance
(use-package! magit) ; no deferral keyword = eager load
(use-package! magit :demand t) ; only use :demand when you truly need eager
;; :init runs BEFORE load (keep minimal — no package functions here)
;; :config runs AFTER load (put all configuration here)
(use-package! company
:hook (prog-mode . company-mode)
:init
(setq company-minimum-prefix-length 2) ; safe: pure var, no company fns
:config
(setq company-idle-delay 0.2)
(company-tng-configure-default)) ; safe: company is loaded here
Deferred Configuration
;; after! is the standard way to configure packages
(after! evil
(setq evil-want-fine-undo t))
;; Wait for BOTH packages
(after! (org evil-org)
(evil-org-set-key-theme '(navigation insert textobjects)))
;; Wait for EITHER package (rare)
(after! (:or company corfu)
(setq tab-always-indent 'complete))
;; IMPORTANT: Don't define functions inside after! — they won't be byte-compiled
;; BAD:
(after! org
(defun pipboy/org-capture () ...)) ; not byte-compiled!
;; GOOD:
(defun pipboy/org-capture () ...) ; defined at top level
(after! org
(setq org-capture-templates ...)) ; only set vars inside after!
Hooks
;; Single hook
(add-hook! python-mode #'pipboy/python-setup)
;; Multiple hooks, one function
(add-hook! (python-mode ruby-mode) #'pipboy/common-setup)
;; Multiple functions, one hook
(add-hook! org-mode
#'org-indent-mode
#'visual-line-mode
#'pipboy/org-extras)
;; Inline form (wrapped in lambda automatically)
(add-hook! 'after-save-hook
(when (derived-mode-p 'org-mode)
(org-update-all-dblocks)))
;; Buffer-local variables on mode activation
(setq-hook! 'python-mode-hook
fill-column 88
tab-width 4)
;; Self-removing hook (fires once)
(add-transient-hook! 'doom-first-file-hook
(require 'my-expensive-package))
;; Remove a hook
(remove-hook! python-mode #'anaconda-mode)
Keybindings
;; Global
(map! "C-c /" #'comment-line)
;; Evil states: :n normal :v visual :i insert :o operator
;; :m motion :e emacs :r replace :g global (no evil)
(map! :n "j" #'evil-next-visual-line ; normal only
:n "k" #'evil-previous-visual-line
:nv "gc" #'comment-dwim) ; normal + visual
;; Leader key (SPC)
(map! :leader
:desc "Find file" "f f" #'find-file
:desc "Recent files" "f r" #'recentf-open-files)
;; Prefix groups (shows in which-key)
(map! :leader
:prefix ("o l" . "llm")
:desc "Chat" "l" #'gptel
:desc "Send" "s" #'gptel-send)
;; Mode-local (prefer :map over :mode for non-evil bindings)
(map! :map org-mode-map
"C-c n" #'org-next-visible-heading)
;; Conditional (only bind if module active)
(map! :leader
(:when (modulep! :tools magit)
:desc "Git status" "g s" #'magit-status))
;; Local leader (SPC m or ,)
(map! :localleader
:map python-mode-map
"t" #'python-pytest-file)
Advice
;; Define and apply in one step
(defadvice! pipboy--silence-message-a (fn &rest args)
"Suppress messages from FN."
:around #'some-chatty-function
(let ((inhibit-message t))
(apply fn args)))
;; Override entirely
(defadvice! pipboy--better-find-file-a (filename)
"Open files in a smarter way."
:override #'find-file
...)
;; Apply to multiple targets
(defadvice! pipboy--common-fix-a (&rest _)
:before #'function-a
:before #'function-b
(do-the-thing))
;; Remove advice
(undefadvice! pipboy--silence-message-a
:around #'some-chatty-function)
Module Feature Detection
(modulep! :tools magit) ; module enabled at all?
(modulep! :tools magit +forge) ; specific flag enabled?
(modulep! :lang python +pyright) ; language flag?
;; Common pattern: conditional config
(when (modulep! :completion company)
(after! company
(set-company-backend! 'python-mode '(company-capf))))
Patterns & Idioms
Defining a Minor Mode
(define-minor-mode pipboy/focus-mode
"Minimal distraction-free mode."
:lighter " Focus"
:keymap (make-sparse-keymap)
(if pipboy/focus-mode
(progn
(writeroom-mode +1)
(hide-mode-line-mode +1))
(writeroom-mode -1)
(hide-mode-line-mode -1)))
Interactive Command with Prefix Arg
(defun pipboy/open-notes (&optional arg)
"Open notes. With prefix ARG, open in other window."
(interactive "P")
(let ((file (expand-file-name "inbox.org" pipboy/gtd-directory)))
(if arg
(find-file-other-window file)
(find-file file))))
Setting LSP/Company Backends
;; Set company backends for a mode (Doom helper)
(set-company-backend! 'python-mode
'(company-capf company-files))
;; Set LSP server for a language
(set-lsp-priority! 'pyright 10) ; prefer pyright over pylsp
Org Capture Templates
(after! org
(add-to-list 'org-capture-templates
'("t" "Task" entry
(file+headline (concat pipboy/gtd-directory "inbox.org") "Inbox")
"* TODO %?\n:PROPERTIES:\n:CREATED: %U\n:END:\n%i"
:empty-lines 1)))
Custom Transient Menu
(require 'transient)
(transient-define-prefix pipboy/llm-menu ()
"LLM operations."
["Send to backend"
("c" "Claude" pipboy/gptel-send-claude)
("g" "Groq" pipboy/gptel-send-groq)
("n" "Gemini" pipboy/gptel-send-gemini)]
["Settings"
("b" "Set default backend" pipboy/gptel-set-default-backend)
("m" "gptel menu" gptel-menu)])
Writing a Simple Package
;;; pipboy-utils.el --- Personal utilities -*- lexical-binding: t -*-
;;
;; All files should have lexical-binding: t in the header.
;; This enables lexical scope, required for closures and better performance.
;;; Code:
(defgroup pipboy nil
"Personal Emacs utilities."
:group 'tools
:prefix "pipboy/")
(defcustom pipboy/something nil
"Description of this option."
:type 'string
:group 'pipboy)
;;;###autoload
(defun pipboy/exported-command ()
"Docstring. First sentence is the summary line — keep it under 70 chars.
Extended description here. Document ARGS in CAPS when referenced."
(interactive)
...)
(defun pipboy--internal-helper (arg)
"Internal helper — double-dash means private."
...)
(provide 'pipboy-utils)
;;; pipboy-utils.el ends here
Performance Rules
| Rule | Why |
|---|
lexical-binding: t in every file | Enables lexical scope + compiler optimizations |
Defer with :commands, :hook, :after | Don't load at startup what can wait |
Avoid require at top level in config | Use after! or :after instead |
Prefer setq over customize-set-variable unless triggering setters | Faster, explicit |
Use setq! for defcustom vars with setters | Triggers setter correctly |
Don't (require 'cl) — use (require 'cl-lib) | cl is deprecated |
Use cl-loop, cl-mapcar, cl-find-if over manual loops | Idiomatic, readable |
when-let* / if-let* over nested when + let | Cleaner early exit |
Common Mistakes to Avoid
;; ❌ Defining functions inside after! (not byte-compiled)
(after! org (defun my-fn () ...))
;; ✅ Define at top level, configure inside after!
(defun my-fn () ...)
(after! org (setq org-something 'value))
;; ❌ Using :ensure t (Doom uses straight.el, not package.el)
(use-package! foo :ensure t)
;; ✅ Declare in packages.el, configure in config.org
(package! foo) ; packages.el
(use-package! foo :defer t) ; config.org
;; ❌ Direct add-hook with old-style function list
(add-hook 'python-mode-hook 'func1 'func2) ; wrong signature
;; ✅ Use add-hook! or call add-hook once per function
(add-hook! python-mode #'func1 #'func2)
;; ❌ Calling package functions in :init
(use-package! company :init (company-tng-configure-default))
;; ✅ Package functions go in :config (after load)
(use-package! company :config (company-tng-configure-default))
;; ❌ rainbow-delimiters-mode called as a function with arg
(after! python (rainbow-delimiters-mode t))
;; ✅ Hook it properly
(add-hook! python-mode #'rainbow-delimiters-mode)
;; ❌ Non-namespaced user functions (pollutes global namespace)
(defun my-org-setup () ...)
;; ✅ Always use pipboy/ prefix
(defun pipboy/org-setup () ...)
;; ❌ Hard-coded paths in config
(setq org-directory "~/w/roam/")
;; ✅ Use defined variables (already set in preamble)
(setq org-directory pipboy/org-notes)
Debugging Toolkit
;; Check if a package is loaded
(featurep 'evil)
(featurep 'org)
;; Find what's hooking into a mode
(describe-variable 'python-mode-hook)
;; Trace function calls (heavy — disable after use)
(trace-function #'some-function)
(untrace-function #'some-function)
;; Time a form
(benchmark-run 100 (org-element-parse-buffer))
;; Inspect a keymap
(describe-keymap 'evil-normal-state-map)
;; Find what's bound to a key
(describe-key (kbd "SPC l"))
;; Check which-key for a prefix
;; Just press SPC and wait — which-key shows all bindings
;; Profile startup
(setq use-package-compute-statistics t)
(use-package-report) ; after startup
;; See load order
(setq force-load-messages t) ; verbose during load
;; Check variable's value and origin
(describe-variable 'gptel-backend)
Config.org Documentation Standard
Every section must have prose above the #+begin_src block:
** Package Name
[[https://github.com/user/repo][package-name]] — one sentence on what it does and why it's here.
Requirements: =SOME_API_KEY= env var, or ~M-x package-login~ on first use.
| Key | Action |
|-------------+---------------------|
| =SPC o x y= | does the main thing |
| =SPC o x z= | secondary action |
#+begin_src emacs-lisp
(use-package! package-name
:hook (relevant-mode . package-name-mode)
:config
(setq package-name-option value)) ; explain WHY if non-obvious
#+end_src
Rules:
- Prose above the block, not as code comments (keep code clean)
- Inline comments only for non-obvious logic — not narrating every line
- Org tables for keybindings and option lists
- Link to upstream repo when the package is non-obvious
- Document required env vars, external tools, first-run steps
Workflow for Any Config Task
- Check for a Doom module first — use the
doom-module skill
- Read before writing — use
find-config to locate existing related config
- Declare packages in
packages.el, configure in config.org
- Follow naming —
pipboy/ prefix on everything user-defined
- Defer loading — use
:hook, :commands, :after, or after!
- Document in
config.org — prose + keybinding table above source block
- Run doom sync — always after any change to packages.el or init.el
- Verify — reload Emacs and check
*Messages* / *Warnings* buffers