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 yourorg-agenda-mode-hook
value, you'll need to either restart Emacs after installing the new version or remove the existing hook usingM-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:
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))))
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.