Amy Pillow UP | HOME Fedi

Directory-local Org Agendas

July 13, 2025

Mea culpa, I really thought I had tested whether the method of limiting dir-locals to the current directory, non-recursively, actually worked. Specifying a subdirectory does not, in fact, make it non-recursive by default. I updated it to unset the special subdirs dir-local, and it has been tested on Emacs 30.1.

July 12, 2025

I made some improvements while using this over the last week, however the changes are not backward compatible. If you have a (lambda () ...) form for your org-agenda-mode-hook value, you'll need to either restart Emacs after installing the new version or remove the existing hook using M-x remove-hook RET org-agenda-mode-hook RET and select the weird looking form.

I switched to a (defun ...), which returns a function symbol that can be reused, even if the definition of the function changes. As long as I keep the same symbol name, any future updates can be installed without any extra thought.

I recently moved to Germany, which has been a lovely experience for the most part. However, there are a lot of things to keep track of in an international move, and Org Mode has proven indispensable for this. My partner is also making the leap across the pond soon, and we have a lot of shared responsibilities. Coordinating these was getting difficult. So, last weekend, I spent some time coming up with a solution.

On a related note, my partner, Jay, has been getting very good [src]​ at using Org Mode along with git for making websites. The amount of Emacs skills he's picked up in just a few months working on it has been incredible to watch. I have yet to really get him into Emacs-lisp, but he's already fallen into the trap of Emacs; he will learn to love lisp eventually.

Naturally, then, Org Mode should be the best way to manage our shared tasks and responsibilities. I may write another article in the future about how we set it up on our phones using Syncthing and Orgzly Revived, but for this article, I want to focus on a problem that has bugged me since starting to use Org Agenda so many years ago, and a potential solution I found recently that, while not the most elegant, is a nice trade-off in my opinion.

The idea of sharing an agenda with other people is very natural to me. I have two separate calendars for work and personal events, one of which is semi-public. I would like to be able to, for example, share my screen in a virtual meeting and show my work agenda without mixing in any personal tasks.

Ideally, Org Agenda should know that when it is launched from a file in, let's say a ~/work directory, only files within that directory should be considered for building the agenda.

Also, the configuration of the agenda files and other Org settings should be independent of the location of the folder. In other words, moving the ~/work folder to a different location shouldn't affect how the agenda looks when launched from within it. This is even more necessary for a shared agenda with multiple people because hardly any two file-systems are alike. It would be an extra burden to manually coordinate and maintain a consistent file structure outside of the synced folder.

It seems like a job very well suited to directory-local variables1. However, my previous attempts to modify the behavior of Org Agenda through file- and directory-local variables had proven fruitless, and I gave up on the problem more than a year ago.

The potential for a solution here feels like it could unlock Org Agenda being used as a shared project management tool in many more places. My mind immediately goes to long term planning of open source projects, given my background and given that most forges can render Org files as html, usually by default.

The idea that the management of a project is something that should exist outside of a project has always bugged me on a spiritual level. I mean, what actually is a Github milestone? From a practical point of view it is a layer in the hierarchy of project tasks that exists somewhere in between low-level issues and the mission statement. But what is it? From an informatics point of view, a milestone is data that is tethered to, and only exists on, a specific host. Milestones may be accidentally stored in various distributed caches, but that is not the goal nor is it reliable. If your git forge were to vanish tomorrow, most of the code, and the history of that code, will be recovered from peoples' local copies, but project management tools, like milestones, would likely be irrecoverable because of the reliance on a single node. In some cases, it is difficult to transfer milestones and such between forges even when both forges are still accessible.

On the other hand, using a plain text file to hold a project README seems like a no-brainer. If somebody showed me a cool project that had a README.ai or a README.docx, I would probably back away slowly. But we generally put up with having little to no control over our project management tooling, it might as well be a proprietary binary format.

To keep this article a practical length, let's quickly move on from my spiritually-adopted beliefs about plaintext and project management to the solution at hand.

Unknowingly, the issue that I ran into before with directory-local variables is that Org Agenda buffers, being entirely in-memory and lacking a corresponding file, do not automatically trigger loading dir-local variables. However, these buffers do still have a default-directory, which is enough for the built-in function hack-dir-local-variables-non-file-buffer to work with. That function name seemed almost comically well-suited for my problem, and I was suspicious that was all I needed. In a way it is a bit more complicated, and that is where the trade-off I mentioned is.

;; .dir-locals.el

;; Does not work!
((nil
  (org-agenda-mode-hook . hack-dir-local-variables-non-file-buffer)))

Changing the value of the org-agenda-mode-hook locally is not sufficient because this customization to the hook is, itself, not loaded by default. It's a chicken or the egg problem. What we need is to globally modify org-agenda-mode-hook:

;; .dir-locals.el

;; Kinda working...
((nil
  (eval . (add-hook 'org-agenda-mode-hook
                    'hack-dir-local-variables-non-file-buffer))))

This global change is compatible with my requirements above because it is not specific to any particular folder. But it is still a shame that globals seem necessary.

This approach works most of the time, but there are a few edge cases. Issues arise because the agenda buffer is not always created from scratch, especially when using org-agenda-sticky. If an existing buffer of the same name already exists, it may be reused in some circumstances. In that case, the reused buffer will carry over the original default-directory, which may not match your current directory.

So, I modified the org-agenda-mode-hook to first cd (change directory) to that of the most recent buffer. Then, when hack-dir-local-variables-non-file-buffer is evaluated, it will be from within the same directory as the buffer from which you launched the agenda.

I just love that name. "Oh, would you like to hack a buffer that doesn't have a file associated with it to use directory local variables anyways?" When was this very specific function introduced? This program is full of mysteries.

;; .dir-locals.el

;; Good-er!
((nil
  (eval . (add-hook
           'org-agenda-mode-hook
           (defun hack-dir-locals-other-buffer ()
             "Apply directory-local variables from the `other-buffer'."
             (cd (buffer-local-value
                  'default-directory
                  (other-buffer (current-buffer) t)))
             (hack-dir-local-variables-non-file-buffer))))))

One more issue I had to tackle was cache invalidation, which can be done easily by shadowing the cache variable while hacking the dir-locals.

;; .dir-locals.el

;; Good!
((nil
  (eval . (add-hook
           'org-agenda-mode-hook
           (defun hack-dir-locals-other-buffer ()
             "Apply directory-local variables from the `other-buffer'."
             (cd (buffer-local-value
                  'default-directory
                  (other-buffer (current-buffer) t)))
             (let (file-local-variables-alist)
               (hack-dir-local-variables-non-file-buffer)))))))

I recognize there are probably some edge cases here, I can't realistically test all possible window configurations, but generally, other-buffer seems to be a reliable way to get the buffer that launched the agenda.

So, that is basically all you need to tailor Org Agenda views depending on which folder you are in. Now, what modifications should you actually make? The obvious one is to update the variable org-agenda-files to only use files local to the directory.

In order to keep my promise to myself that these settings will be independent of the location of the directory, the org-agenda-files must be specified as relative paths. In order to accomplish that, all files need a common understanding of what the root directory is, or where they are in relation to it. Otherwise, a relative path might mean two different things to two files that are in different sub-directories.

One way to do it is to simply not deal with that level of complexity. You can limit the scope of directory-local variables to only include a single directory non-recursively. That way, each file will have a common root for relative paths, because they are all guaranteed to be in that root folder.

The special dir-local variable subdirs specifies whether the settings should apply to all files in all subdirectories, for the specified mode. Unsetting it will have the effect I want.

;; .dir-locals.el

((nil
  (subdirs)
  (eval . (add-hook
           'org-agenda-mode-hook
           (defun hack-dir-locals-other-buffer ()
             "Apply directory-local variables from the `other-buffer'."
             (cd (buffer-local-value
                  'default-directory
                  (other-buffer (current-buffer) t)))
             (let (file-local-variables-alist)
               (hack-dir-local-variables-non-file-buffer)))))
  ;; Add all Org files in current directory to org-agenda-files
  (org-agenda-files . ("."))))

Of course, any number of other settings related to Org Mode and agendas can be put here, such as specifying custom todo keywords or the behavior of blocked tasks, and they will apply to both Org Mode buffers and Org Agenda buffers launched from within that folder2.

Emacs is a pretty lightweight dependency, all things considered, and some clever Makefile targets or package.json scripts could launch Emacs with pre-configured agenda views. Running this command from a folder with a .dir-locals.el like the one above will show all outstanding todo items in the folder:

emacs -f hack-dir-local-variables-non-file-buffer -f org-todo-list

Yes, that's right, it's our friend hack-dir-local-variables-non-file-buffer, one last time! Because Emacs was launched without a file name specified, the directory-local variables won't be loaded by default. So the agenda function, either org-agenda-list or org-todo-list, will have to be specified after this beautifully named function.

And that's pretty much all I have to say about it! There are a lot of other ways to customize the agenda to your projects' needs. While I'm not a huge fan of adding a global hook, the return value is pretty great. Maybe someday I could try to extend Org Agenda to have better built-in support for this kind of usage, but that is a little more involved than a 10-line config file.

Anyways, I hope this is useful, and I hope more people share agendas with each other. It really is a great way to keep track of everything in a collaborative way.

Footnotes:

1

Emacs can be configured such that certain buffers contain local copies of global variables, with different values. The user is in complete control of this; Emacs will prompt you to accept or deny certain modifications. At this prompt you can also press ! to mark them as safe and not be asked again about the specific values being applied.

One way to configure this is by using a 'dotfile', which is typically invisible in most file browsers, called .dir-locals.el. In it is a lisp data structure, it is not a program, it cannot be evaluated, it is a configuration file which specifies variables for files in the same directory. Because it is not evaluated, you want to try to rely on read syntax to build this file, rather than expressions. This is pretty limiting though, so one special variable that you can specify, that doesn't really exist as a variable, is eval. The value of eval should be an expression, and that expression alone will be evaluated while the rest of the values in the .dir-locals-.el file will be left 'as is'. eval can actually be specified multiple times, with each expression being evaluated in order.

The data structure of a .dir-locals.el is a list of lists of conses (or lists of conses). The outer most list contains mode specifications. Each specification itself is a list, with the first element being the major-mode symbol, or nil to match any mode, and the rest of the items being (var . val) conses.

;; .dir-locals.el

((my-major-mode
  (fill-column . 80)
  (eval . (+ 1 2)))
 (nil
  (in-every-mode . yes)))


The outer most list can also specify folders as a list beginning with a string. After the folder name comes the mode specifications like above.

;; .dir-locals.el

(("subdir"
  (my-major-mode
   (fill-column . 80)
   (eval . (+ 1 2)))
  (nil
   (in-every-mode . yes))))
2

You can also directly specify variables for org-mode and org-agenda-mode individually instead of nil. This would mean those settings would not apply to other types of files in the same directory, which might be desirable.

Luckily, the eval form really only makes sense from a file buffer, because it is essentially the bootstrapping expression, so it does not need to be repeated for org-agenda-mode. In order to support launching from the terminal in this case you'll need to add -f org-mode before to enforce a specific startup major mode. Most users start in fundamental-mode, but if inhibit-startup-screen is set it will likely be the default scratch buffer mode: lisp-interaction-mode. That, of course, is customizable though; I already have my initial-major-mode set to org-mode, because I'm such a fangirl :D

Similarly, the org-agenda-files only need to specified for org-agenda-mode, but there are other Org features that can rely on this variable, including org-refile-targets, so I think it is best to keep this one in-sync between both modes.

Created: 2025-07-07

Last modified: 2025-07-26