The hook system
A hook is a separate program Keystone can hand a code block to, taking back
whatever it returns as ordinary content. Diagram rendering is the obvious use:
an author writes a fenced mermaid block, and a hook turns it into a figure.
The point is that this happens without changing the engine. A hook is a container the author wires in alongside Keystone, so adding one is a compose edit. Available hooks lists what is published.
What an author writes
The fence stays a plain fence, so GitHub and an editor preview still render it:
```mermaid
flowchart LR
A --> B
```
Nothing marks it for Keystone. The hook claims the language, and a block whose language is that one is handed over.
The language is the fence's first class, as everywhere in Pandoc, so putting
another class in front of it — {.text .mermaid} — makes the block something
else that happens to contain the same source, and it renders as a listing.
Attributes come from an ordinary shortcut wrapped around the fence, so a hook's output is styled the way anything else is. A template that wires a hook may name a shortcut of its own for that; the mechanism is the same either way.
Where hooks reach
Hooks see the manuscript. A fence written into a shortcut definition renders as code, because shortcut bodies are expanded after hooks have run.
Wiring one in
Diagrams need none of this
core-diagrams ships the renderer wired in, and
Diagrams is all you need to read. To put that same
hook in a different template, see
Wiring a published hook.
What follows is the mechanism every hook shares. For one you write yourself, Writing a hook is short, and a shim in bash is a working example.
Your template comes preconfigured for this: what you add is the hook's own service, and the stub names it.
# No user — a hook runs as its image's own UID, which is why the socket it
# binds is 0666 for the engine to reach.
services:
keystone:
# …unchanged, except the depends_on that names your hook…
depends_on:
charts:
condition: service_healthy
# the service you add
charts:
image: ghcr.io/example/chart-hook:v1
healthcheck:
test: ["CMD", "test", "-S", "/hooks/charts.sock"]
interval: 30s
start_interval: 1s
timeout: 2s
retries: 3
start_period: 30s
network_mode: none
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
environment:
HOME: /tmp
HOOK_SOCKET: /hooks/charts.sock
# A hook reads no project.conf — it gets what you name here, so pass
# through each setting it takes.
EXAMPLE_SETTING: ${EXAMPLE_SETTING:-}
volumes:
- hooks:/hooks
# both already there — the socket the hook binds, and Keystone's own cache
volumes:
hooks:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=1m,mode=1777
hooks-cache:
HOOK_SOCKET names the socket, and the hook takes its name from that file, so
charts.sock is the hook charts. You choose it: the name settles which hook
serves a language when two claim one.
depends_on is not optional, and the condition has to be service_healthy
Keystone looks for a socket once, before the build starts. A container that
has started has not necessarily bound its socket yet, and losing that race
is not an error — it is a book with every claimed fence rendered as a code
listing. service_started returns too early to prevent it.
The healthcheck is what makes that wait possible. start_period keeps a slow
start from reading as a broken one; start_interval is how often the check runs
during it, and so how long your first build waits — left out, Docker falls back
to the much slower interval. Declare one without the other and the fast poll
never happens. Both need Docker Engine 25 or newer.
The check asks whether your hook has bound yet, not whether it is still answering. One that bound and then died is reported as a hook that did not answer instead.
Leave the socket volume as it ships
Its declaration is what keeps that check meaningful. Change it and a socket can outlive the hook that bound it, so the check passes while nothing is listening.
A bind mount brings a second problem: on macOS it does not enforce the
socket's permission bits, so a hook that would be unreachable on Linux
appears to work. It is :ro on Keystone's side, which still permits
connecting.
The cache needs nothing — its volume is already in your compose file, and Keystone fills it on demand.
Wiring a published hook
The service above is most of it. A hook someone else built and signed asks for a little more:
- Take the image pinned. Copy the
image:line out of the template that ships the hook — fordiagrams, the.docker/docker-compose.yamlincore-diagrams, which names it by digest. A hook named by tag moves under you between builds. - Uncomment
depends_onon thekeystoneservice, with the conditionservice_healthy— the warning above says what losing that race looks like. - Give the hook its settings, if it takes any. The
diagramshook's go inproject.confunder Diagrams, and its own service names each of them to pass it through. -
Check its signature.
verifyin yourMakefilechecks the engine; a second block checks the hook, naming its image and the identity that published it — fordiagrams:@docker run --rm <the cosign image verify already names> verify \ <the hook image, exactly as pinned above> \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp '^https://github\.com/knight-owl-dev/keystone-hook-diagrams/'A different publisher signs the hook, so the identity is its own — which is the point of checking.
What you wire yourself is yours to keep: pinned, moved when a new version ships, and verified. A template that ships a hook does all of that for you.
What it costs when you have none
Nothing. Keystone looks for a hook socket once, before the build starts, and a project that wires none never loads the hook filter at all.
The trust boundary
A hook is the one way anything reaches out of a build, and it is there only because you wired it in. Sandboxing sets out what that means for a hermetic build.
A hook that fails, times out, or answers with something unusable stops the build and names the hook — see A hook that fails.
Caching
Keystone remembers what a hook answered, so the same diagram is rendered once however many times it appears and a rebuild asks the hook for nothing. A renderer that has been upgraded or reconfigured is asked again rather than handing back what no longer holds; one that does not say what its answers depend on is never remembered at all, which is correct and slower.
The cache lives in a Docker volume the engine owns, so it survives between
builds, and both make clean and make down leave it alone. It bounds itself,
dropping the least recently wanted entries past
a size you can set — a single budget
shared by every hook a project wires. make reset clears that volume, which
frees its disk and forces a re-render when a hook has changed without saying so.
Your manuscript and artifacts/ are bind mounts and survive it; a named volume
of your own goes with it unless you declared it external.
To look at what a hook produced, or to keep the cache across a make reset, set
KEYSTONE_HOOKS_CACHE to
artifacts/.hooks-cache. Paths are read from your project's root, and each
entry is a directory holding one reply and the assets it carried. make clean
then empties them with everything else.
What a wired hook leaves running
A hook is a service, so it stays up when a build finishes — that is what spares
the next build its startup. It also holds whatever it loaded for as long as it
runs, per project. make ps shows what is up, make down stops it, and
KEYSTONE_HOOKS_KEEP_ALIVE decides
whether a finished build does that for you. The stop leaves the
cache behind, so what it costs is the startup and nothing more.
Writing one
See Writing a hook for the protocol, the obligations a hook takes on, and a working example small enough to read in one sitting.