I have teenage kids, and they are possibly dorkier than me, which is awesome. Since the pandemic, we have had a D&D game going, off and on, with some of their friends and friends' parents. It's been super fun. I am, however, the perma-DM. I am 99% fine with this, as I love the world building aspects which the DM gets to lean more heavily into than players.
Sometime this past year I started doing my session prep in Claude Code. It started with discussing ideas for arcs and settings, and rapidly turned into shockingly useful session prep. While I am impressed by frontier models' ability to program, I am in awe of their ability to help plan and prep D&D because this is very much about predicting human behavior, setting up interesting situations and being ready for what real people may do in them. That Claude can make rustc sing is nice, that it can make my players sing was unexpected.
I have, for a long time, been keeping my notes in Obsidian and git (private GH repo), laid out more or less like:
├── encounters
│ ├── agadol_ambush.md
│ ├── Audience with Kerral.md
│ └── [more, elided ...]
├── factions
│ ├── Arjun.md
│ ├── Tollkeepers.md
│ └── [more elided ...]
├── ideas
│ ├── beechport_arc_ideas.md
│ ├── The Red Dragon.md
│ └── [more, elided ...]
├── npc
│ ├── Agustin.md
│ ├── Chalan.md
│ └── [and more, elided...]
├── pc
│ ├── Borin - Notes.md
│ ├── Ethex - Notes.md
│ ├── Nameless - Notes.md
│ └── Ragux - Notes.md
├── places
│ ├── Aurum.md
│ ├── Beechport.md
│ └── [and more, elided ...]
└── sessions
├── [and more elided ...]
├── Session 026 - 2026-01-03.md
└── Session 027 - 2026-01-17.md
Claude took to this structure like, well, a coding agent to a tree of markdown :-) I have long described myself as working best when I can "pair think" -- put me at a whiteboard with another programmer and I, at least, am more than 2x as effective. Cannot vouch for any given pair-thinker, but this seems to be common enough that I suspect the sum is greater than the parts. Rubber ducking, even with Jonathan Aquino's excellent assistance, Thank you for that video, Jon, I still use it! just is not the same. Turns out DM planning works this way to, and Claude is a good enough pair-thinker that the results are great.
Mechanically, I fire up a claude-code web session to plan the next session and just let thoughts wander all over. I let claude organize what we come up with into NPC, Faction Idea, or Session files as it makes sense, then iterate on key things for the next session. I generally have a bunch of "here is other stuff going on" in a per-arc doc (beechport_arc_ideas.md above, for example), which act as a catch all for "this might happen, and I think here are some things that might fall out of it..." type thoughts. The arc-doc is the big picture context for the next session of planning, and it will refer to key NPCs, places, factions, etc.
Historically I used something similar to the Lazy DM approach, but have been trying Brennan Lee Mulligan's toy approach lately. In both cases, Claude has been able to take a description of the materials I want at the table and produce them well enough to almost just use.
As I mentioned, the thing that has most impressed me is in encounter planning. Claude is surprisingly good at predicting what players will do in given situations, estimating the time for a given encounter, estimating the difficulty, highlighting things which will likely have an emotional impact on a specific player, etc. It can predict good beats to highlight aspects of individual characters even, and help set opportunities for specific individuals to showcase some aspect of themselves. It sometimes gets confused between NPCs and PCs, and will optimize to allow an NPC to shine, but it course corrects on this well enough.
The main thing that burnt me early was when Claude would go off the rails Is it still hallucination if the thing it is hallucinating is completely imaginary and poorly defined in the first place? on aspects of the world or players and I didn't bother to correct it, because, who cares. Later when it referred to past notes the misunderstandings started magnifying and the overall quality as a planning partner deteriorated greatly. Spending time getting on the same page, and getting Claude to record it in the various docs, was well worth it, as now it makes really good connections without help.
The two fun problems in computer science: cache invalidation, naming things, and off-by-one errors. Today I want to talk about the first one.
I've been working on Epithet, an SSH certificate authority Really, an agent, a CA, and policy server that makes certificate-based authentication easy. Part of the system involves a "discovery" endpoint where clients learn which hosts the CA handles. The question: how do you cache this efficiently while still allowing updates to propagate?
The policy server component provides a discovery endpoint for this, which serves a simple JSON document:
{"matchPatterns": ["*.example.com", "prod-*"]}
This content rarely changes, only when you add a new host pattern to your policy, generally. But when it does change, you want clients to pick it up reasonably quickly. My first implementation used aggressive caching:
w.Header().Set("Cache-Control", "max-age=31536000, immutable")
Cache it forever! The URL is content-addressed (/d/{hash}), so if the content changes, the hash changes, and you get a new URL. Problem solved, right?
Not quite. The client learns the discovery URL from a Link header in other responses:
Link: </d/abc123>; rel="discovery"
But the client caches this URL. If the server starts returning a different URL in the Link header, the client won't notice until... when exactly?
The discovery document at /d/abc123 is immutable and cached forever. The client has no reason to re-fetch it. And it has no reason to make other requests that would reveal the new Link header. We've created an immortal cache entry.
The obvious fix: use ETag and If-None-Match for cache revalidation.
w.Header().Set("Cache-Control", "max-age=300") // 5 minutes
w.Header().Set("ETag", `"` + hash + `"`)
if r.Header.Get("If-None-Match") == expectedETag {
w.WriteHeader(http.StatusNotModified)
return
}
This works: after 5 minutes, the client revalidates. If the content hasn't changed, it gets a quick 304. If it has, it gets the new content.
But there's a wrinkle. I want to support deploying discovery documents to a CDN or static file server (probably S3 fronted by a CDN, to be honest). If we rely on each of these URLs sending a 404 or a redirect then we need to either update every URL ever published when we make a change (to redirect to the new location), maintain a very long chain of redirects, or rely on out of band behavior if they start 404'ing (know to go fetch something which will include the new Link header). YUCK.
So, a layer of indirection solves everything, right? What if we separate "what's the current discovery document?" from "what's in that document?"
- A pointer (
/d/current) that says "the current discovery is at /d/abc123"
- The content (
/d/abc123) which is truly immutable
The pointer can have a short cache lifetime. The content can be cached forever. When the content changes:
- Deploy new content at
/d/xyz789
- Update the pointer to redirect to
/d/xyz789
- Clients' cached pointers expire after 5 minutes
- They fetch the pointer, get redirected to the new content
- Old content at
/d/abc123 can stay around (or be garbage collected later)
The built-in policy server is in Go, so the redirect handler is trivial:
func NewDiscoveryRedirectHandler(hash string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=300")
w.Header().Set("Location", "/d/"+hash)
w.WriteHeader(http.StatusFound)
}
}
The content handler remains unchanged with immutable caching:
w.Header().Set("Cache-Control", "max-age=31536000, immutable")
The Link header now always points to /d/current:
w.Header().Set("Link", "</d/current>; rel=\"discovery\"")
HTTP caches handle this beautifully:
- Client requests
/d/current
- Gets 302 redirect to
/d/abc123 with max-age=300
- Follows redirect, gets content with
immutable
- Both responses are cached appropriately
- After 5 minutes, the redirect expires
- Next request fetches
/d/current again
- Might get same redirect (cache hit on content) or new one
The content-addressed URLs can be served from anywhere: a CDN, S3, a static file server. They never need invalidation logic. The redirect endpoint is the only "dynamic" part, and it's just returning a Location header.
The final implementation is about 10 lines of code. Most of the work was figuring out the right design :-)
The best part: this doesn't mandate a specific implementation. The contract between client and server is just "respect HTTP caching headers."
A policy server could:
- Use this same redirect pattern
- Use ETag with conditional requests
- Use a short
max-age without immutable
- Some other scheme entirely
As long as the server sets appropriate Cache-Control headers and the client respects them, it works. The client just uses a standard RFC 7234 compliant HTTP cache (in my case, github.com/gregjones/httpcache.
Sometimes the best solution is realizing HTTP already solved your problem decades ago.
Want to try Epithet? Check out the GitHub repo.
Kong is my go-to CLI parser in go, so I used it for epithet. It has pretty good built-in configuration handling, I appreciate how it lines up config values with CLI flags by basically treating config as a serialized tree of it's structs. This is great for simple things, but for epithet I needed slightly more complex mapping, with dynamic keys (user identities) and such. I wanted to keep the core of its model though as it's really nice.
So, I wrote a thing to configure it using CUE. CUE seems complicated if you read its docs, because it dives straight into it's prolog-y evaluation roots, but you can basicaly ignore that for simple use cases (and then use it for hairy ones). It turns out I really liked this, so extracted it to a library, meet kongcue.
It works mostly like the built-in Kong config mechanism, except it happily handles YAML, JSON, and CUE syntax, unifies across multiple files elegantly, and allows me to treat validation issues as configuration issues (with useful error messages, lone and column pointers, etc) if you use a config file, or fallback to kong's flag based errors if you don't use a config file. It can also simply generate a CUE schema file for artbitrary config documentation and validation!
Example time! Let's take a small hello-world style kong app:
package main
import (
"fmt"
"github.com/alecthomas/kong"
"github.com/brianm/kongcue"
)
func main() {
var c cli
ktx := kong.Parse(&c, kongcue.AllowUnknownFields("messy"))
ktx.FatalIfErrorf(ktx.Run(c))
}
type cli struct {
Name string `default:"world" help:"The name of the person to greet"`
Stuff bool `required:"" help:"A required flag, about stuff"`
Greet GreetCmd `cmd:"" help:"Issue a greeting"`
Depart DepartCmd `cmd:"" help:"Issue a valediction"`
Config kongcue.Config `default:"./example.{yml,json,cue}" sep:";" help:"Config file paths"`
ConfigDoc kongcue.ConfigDoc `cmd:"" help:"Print config schema"`
message string
}
type GreetCmd struct {
Excited int `default:"0" help:"How excited are you to see this person?"`
}
func (g GreetCmd) Run(c *cli) error {
// elided
}
type DepartCmd struct {
Sadness int `help:"How sad are you to be leaving?" required:""`
}
func (g DepartCmd) Run(c *cli) error {
// elided
}
It has a couple sub-commands, some flags, all of it is just demo-ware. Note the lines:
Config kongcue.Config `default:"./example.{yml,json,cue}" sep:";" help:"Config file paths"`
ConfigDoc kongcue.ConfigDoc `cmd:"" help:"Print config schema"`
These set up configuration handling, and a sub-command to dump the schema and documentation! This does what it looks like, globs across the possible config file names, loads all that match, etc. The schema part is nifty, as it generats config docs, effectively. Invoke example config-doc and you get:
// Configuration schema for validating config files.
//
// This schema is written in CUE, a configuration language that
// validates and defines data. Learn more at https://cuelang.org
//
// To validate your config file against this schema:
// 1. Save this schema to a file (e.g., schema.cue)
// 2. Run: cue vet -d '#Root' schema.cue your-config.yaml
//
// Fields marked with ? are optional. Fields without ? are required.
#Root: close({
// The name of the person to greet
name?: string
// A required flag, about stuff
stuff: bool
// Issue a greeting
greet?: #Greet
// Issue a valediction
depart?: #Depart
messy?: _
})
#Depart: close({
// How sad are you to be leaving?
sadness: int
})
#Greet: close({
// How excited are you to see this person?
excited?: int
})
While this is a bit obscure, it is not that hard to follow even if you are not familiar with CUE's schema/constraint declarations.
If you invoke it with a bad config file, you get a nice config error:
kongcue/example on main ❯ cat bad.yml
name: "test"
stuff: "walrus"
kongcue/example on main ❯
kongcue/example on main ❯ go build && ./example --config ./bad.yml
example: error: stuff: conflicting values "walrus" and bool (mismatched types string and bool):
/Users/brianm/src/github.com/brianm/kongcue/example/bad.yml:2:8
kongcue/example on main ❯
Without a config file (default is not present, none specified) it gives you CLI oriented errors:
kongcue/example on main ❯ ./example
example: error: missing flags: --stuff
kongcue/example on main ❯
It has a simple escape hatch for non-CLI compatible config sections, which you see in the above example:
ktx := kong.Parse(&c, kongcue.AllowUnknownFields("messy"))
This allows the messy thing in the example config, even though it does not appear in the kong struct tree.
name: "Brian"
stuff: true
depart:
sadness: 3
messy:
woof: "meow"
splat: 7
The kongcue.AllowUnknownFields("messy") tells it to just accept anything in the messy value. At some point I'll add a mechanism to define a schema for these, but for now it just relies on deserializing to structs erroring out.
The cue.Value for the config tree is made available as a kong binding, so you can get it in whatever command you need to pull additional values from. This allows epithet policy configs like:
users:
alice@example.com: [admin, eng]
bob@example.com: [eng]
charlie@example.com: [ops]
diana@example.com: [admin, ops]
which can be defined nicely in CUE, but not naturally kong's cli constraints.
For a more complex, and real, schema generated from epithet, take a look at epithet's:
// Configuration schema for validating config files.
//
// This schema is written in CUE, a configuration language that
// validates and defines data. Learn more at https://cuelang.org
//
// To validate your config file against this schema:
// 1. Save this schema to a file (e.g., schema.cue)
// 2. Run: cue vet -d '#Root' schema.cue your-config.yaml
//
// Fields marked with ? are optional. Fields without ? are required.
#Root: close({
// Print version information
version?: bool
// Increase verbosity (-v for debug, -vv for trace)
verbose?: int
// Path to log file (supports ~ expansion)
log_file?: string
// Disable TLS certificate verification (NOT RECOMMENDED)
insecure?: bool
// Path to PEM file with trusted CA certificates
tls_ca_cert?: string
// Start the epithet agent (or use 'agent inspect' to inspect state)
agent?: #Agent
// Invoked during ssh invocation in a 'Match exec ...'
match?: #Match
// Run the epithet CA server
ca?: #Ca
// Run the policy server with OIDC-based authorization
policy?: #Policy
// Authentication commands (OIDC, SAML, etc.)
auth?: #Auth
})
#Agent: close({
// Match patterns
match?: [...string]
// CA URL (repeatable, format: priority=N:https://url or https://url)
ca_url?: [...string]
// Authentication command
auth?: string
// Per-request timeout for CA requests
ca_timeout?: int
// Circuit breaker cooldown for failed CAs
ca_cooldown?: int
// Start the epithet agent
start?: #AgentStart
// Inspect broker state (certificates, agents)
inspect?: #AgentInspect
})
#AgentInspect: close({
// Broker socket path (overrides config-based discovery)
broker?: string
// Output in JSON format
json?: bool
})
#AgentStart: close({})
#Auth: close({
// Authenticate using OIDC/OAuth2 (Google Workspace, Okta, Azure AD, etc.)
oidc?: #AuthOidc
})
#AuthOidc: close({
// OIDC issuer URL (e.g., https://accounts.google.com)
issuer: string
// OAuth2 client ID
client_id: string
// OAuth2 client secret (optional if using PKCE)
client_secret?: string
// OAuth2 scopes (comma-separated)
scopes?: [...string]
})
#Ca: close({
// URL for policy service
policy: string
// Path to ca private key
key?: string
// Address to listen on
listen?: string
})
#Match: close({
// Remote host (%h)
host: string
// Remote port (%p)
port: int
// Remote user (%r)
user: string
// Connection hash (%C)
hash: string
// ProxyJump configuration (%j)
jump?: string
// Broker socket path
broker?: string
})
#Policy: close({
// Address to listen on
listen?: string
// OIDC issuer URL
oidc_issuer?: string
// OIDC audience (client ID)
oidc_audience?: string
// CA public key (URL, file path, or literal SSH key)
ca_pubkey?: string
// Default certificate expiration (e.g., 5m)
default_expiration?: string
users?: _
defaults?: _
hosts?: _
})
It's not good docs, but it directly generated from the code, so it is at least accurate, and close to free :-)
I think the CUE website does a pretty bad job of explaining its value, to be honest. It's a darned useful tool even in the small. The website just hides that basic usefulness behind piles of articles of the "look at all the really powerful stuff it can do" for advanced cases.