Writing a hook
A hook listens on a Unix socket and answers two questions: what it is, and what a block becomes. That is the whole contract. It shares nothing else with Keystone — not a base image, not a language, not a library.
The shape of one
Keystone connects, sends one request, and reads one reply. There is no session to keep and no connection to manage, so a hook can be a program that reads stdin and writes stdout, with something in front of it doing the listening:
FROM alpine:3.24
RUN apk add --no-cache bash socat
RUN mkdir -p /hooks && chmod 1777 /hooks
RUN addgroup -g 1001 -S hook && adduser -u 1001 -S -G hook hook
COPY handler.sh /handler.sh
USER 1001:1001
ENTRYPOINT ["sh", "-c", \
"exec socat -t 30 \"UNIX-LISTEN:${HOOK_SOCKET:?HOOK_SOCKET is required},fork,mode=0666\" EXEC:/handler.sh"]
socat is one way to avoid writing a server, not a requirement. A hook in a language with sockets should use them directly.
socat needs -t
Without it, socat kills the handler half a second after Keystone half-closes the connection — before anything that renders could answer. Set it to at least the timeout the build allows, or yours is the one that decides.
Name and precedence
The socket's filename is the hook's name: diagram.sock is the hook diagram.
One hook may claim several languages.
Take that path from HOOK_SOCKET, and refuse to start without it. The name
belongs to whoever wires the hook in, and it settles precedence — see
Wiring one in.
What is yours, and what is Keystone's
A hook decides what a block becomes. Where that answer sits, what it is styled like, how it is numbered and cross-referenced — all of it belongs to the shortcut the author wrapped the block in and to the machinery behind it. A hook that reaches for any of it fights the book it is drawing into.
| Keystone | Expanding what you answer with and placing it: width, alignment, caption styling and numbering, cross-references, and packaging any asset into the book. |
| You | What the block becomes, and what form that answer takes in each format. Everything inside the block, which is your language and not Keystone's to read. |
Keystone parses nothing inside a block, so a directive, a theme, a syntax error and a configuration setting are all yours to interpret and yours to refuse.
If your answer is a picture
Not every hook draws one — an answer is markup, and a table or a callout is as good an answer as a figure. For the ones that do draw, these are easy to get backwards.
Render for scaling; do not fix a size. The author sets a figure's width, and
the container scales your asset to it — so a size you render at does not survive
to the page, and lettering that looked right in your renderer arrives at
whatever that scaling made of it. What you owe is an asset that is still sharp
when scaled: a vector where the format takes one, and enough pixels where it
does not. transform is where that decision is made, per format.
Hand over the caption; do not draw it. If your language has a title, it belongs in the reply as the image's alt text, where Keystone sets it in the book's own caption style — numbered alongside every other figure, in the formats that number them. Drawing it into the picture as well puts it in the book twice, in two typefaces, and only one of them is the book's.
What a picture is stops at its edges; how it is placed begins there.
describe
Asked before anything is walked, once per Pandoc run. Keep it cheap. It carries nothing at all, because it cannot be phrased in a version that has not been agreed yet — which also means its shape is fixed for as long as the handshake is.
Keystone sends:
{ "op": "describe" }
The hook answers:
{
"protocols": [1],
"targets": ["mermaid", "dot"],
"identity": "2.1.0/theme=dark",
"formats": [["pdf", "docx", "odt"], ["epub"]],
"min_keystone_version": "v2.4.0"
}
| Field | |
|---|---|
protocols |
wire versions the hook speaks. Keystone picks the newest it shares |
targets |
code-block languages the hook claims |
identity |
optional; what your answers depend on, so they can be cached (below) |
formats |
optional; output formats answered alike, grouped (below) |
diagnostics |
optional; what is wrong with how you were configured (below) |
min_keystone_version |
optional; a Keystone older than this refuses to run the hook |
Saying when your answers change
identity is an opaque string covering everything that affects your output:
your own version and your own configuration both do, so both belong in it.
{ "protocols": [1], "targets": ["mermaid"], "identity": "2.1.0/theme=dark" }
The same inputs must produce the same string on every run, and any change that would alter your output must alter it.
A hook without an identity is never cached
You are asked for every block, in every format, on every run. Correct, and slow. A fair trade while you are still finding the shape of a hook, and not what you ship.
Saying what is wrong with your configuration
A hook is configured by the project that wired it in, and a setting you cannot act on is neither Keystone's to interpret nor any block's fault. Say so here:
{
"protocols": [1],
"targets": ["mermaid"],
"diagnostics": [
{ "severity": "warning",
"problem": "no serif font is installed",
"effect": "Diagrams letter in the fallback font." },
{ "severity": "error",
"problem": "DIAGRAM_THEME is 'midnight', which is not a theme this renders",
"choices": ["dark", "light", "neutral"],
"remedy": "Set it to one of those, or leave it empty." }
]
}
WARN: hook 'diagrams': no serif font is installed
Diagrams letter in the fallback font.
ERROR: hook 'diagrams': DIAGRAM_THEME is 'midnight', which is not a theme this
renders
Valid: dark, light, neutral
Set it to one of those, or leave it empty.
warning prints and the build carries on, unless the author has
strict builds on.
error stops it, naming you. Every error in one reply is reported together,
and warnings ahead of them, so a misconfigured project is not fixed one build at
a time.
Every entry carries a severity, and everything beside it is a callout — the
same ones a refusal takes.
Formats you answer alike
A hook is asked once per format, and often answers several of them the same way. Say which ones share an answer, and Keystone asks you once for the group:
{
"protocols": [1],
"targets": ["mermaid"],
"formats": [["pdf", "docx", "odt"], ["epub"]]
}
Only you know which outputs collapse. Without this, each format is its own question and you are asked many times over for the same answer.
Group a format only where the reply really is identical. Correcting a grouping
later is safe and needs no new identity.
A format may appear in one group only
Naming it twice is refused. A format you leave out is asked for
individually, which is what happens for every hook that declares no
formats at all.
transform
Answer every format you are asked for. Where your answers coincide, say so in
formats — a hook whose output never varies
declares one group covering all of them.
Keystone sends:
{
"op": "transform",
"target": "mermaid",
"format": "epub",
"content": "flowchart LR\n A --> B"
}
| Field | |
|---|---|
target |
the code-block language, one the hook claimed |
format |
the document being built — pdf, epub, docx, or odt |
content |
the block verbatim, fence line excluded |
The hook answers:
{
"body": "",
"assets": [
{
"name": "flow.svg",
"media_type": "image/svg+xml",
"data": "PHN2Zy…"
}
]
}
| Field | |
|---|---|
body |
Markdown, to be spliced in where the block was |
assets |
optional; what body references, each with a name, a media_type, and base64 data |
error |
instead of a body — why this block could not be rendered (below) |
body can be anything an author could write — a figure, a callout, a table,
several blocks. Whatever shortcut it names is expanded afterwards, so a hook
reaches the whole vocabulary rather than only images.
Reuse a name across replies freely. Keystone files each response separately, so
answering flow.svg for every diagram it renders collides with nothing — name
assets for what they are, not for where they might end up.
An asset reaches the document only through an image
A body that links to a resource instead produces a dead link.
Refusing a block
A block you cannot render is answered with error instead of a body:
{ "error": { "problem": "syntax error on line 3: expected an arrow" } }
It carries callouts and no severity: a block you will not render leaves
nothing for the build to carry on with, so a refusal is always fatal.
Keystone stops the build and prints what you wrote, against the block at fault:
ERROR: hook 'diagram' on a 'box' block starting "A -- B -- C": syntax error on
line 3: expected an arrow
error is the only thing a transform reply says to the author, and only you can
say why a block was refused. What a whole project should hear about goes in
describe instead.
Writing the message
You name the pieces of a message; Keystone assembles them. The wrapping, the indenting and the labels are its, so your diagnostic is laid out exactly like Keystone's own, under the line that names you.
{
"error": {
"problem": "unknown option 'shade' on line 1",
"choices": ["color", "width", "label"],
"remedy": "Drop it, or pick one of those."
}
}
ERROR: hook 'diagram' on a 'box' block starting "{shade: red} A -- B": unknown
option 'shade' on line 1
Valid: color, width, label
Drop it, or pick one of those.
The keys are Keystone's own callouts, one for one, and
Each callout, shown gives an
example of every one with the lines it prints. The Lua there is what a filter
declares; your JSON object carries the same keys. problem is the one to get right: it
reads on from Keystone's colon, so it opens lowercase and ends in no terminator.
because, effect and remedy are whole sentences. describe is the one
callout you cannot send — it carries a Lua function, which no reply can.
Send the keys you have something for. A key Keystone does not render, or one
carrying the wrong kind of value, takes the whole diagnostic down with it: the
build stops, and your message is quoted back rather than printed. That holds at
warning too, because Keystone cannot tell whether a diagnostic it could not
read was going to stop the build. A key set to null reads as one you left out.
Relay a renderer's output as it came, with Unix line endings. Nothing is refused
for carrying a control character — one is shown rather than acted on, ^M for a
carriage return — but CRLF then ends every line of yours with a ^M the author
has to read past.
Text you did not write belongs in verbatim. It is reproduced there as it
arrived, behind a bar that says whose it is, so line numbers and caret alignment
survive.
Translate it in problem as well and the author gets both — a sentence naming
their diagram, and the output to search for:
{
"error": {
"problem": "line 1: 'shade' is not an option a box takes",
"verbatim": "Parse error on line 1:\n {shade: red} A -- B\n ^"
}
}
ERROR: hook 'diagram' on a 'box' block starting "{shade: red} A -- B": line 1:
'shade' is not an option a box takes
│ Parse error on line 1:
│ {shade: red} A -- B
│ ^
Forwarding it untranslated is what to avoid:
Parse error on line 3: ...Expecting 'SEMI', 'NEWLINE', got 'ALPHA' names a
grammar the author never saw.
What a hook owes
- Listen where
HOOK_SOCKETsays, mode0666, and refuse to start without it. Keystone connects as the user who ran the build, not the user inside your container. - Bind only when you are ready to answer. Keystone looks for the socket once, before the build starts, and one that exists but cannot answer reads as a dead hook. Waiting on it is the wiring's healthcheck, not yours to ship.
- Answer within the timeout — 30 seconds by default, and the author can raise it. A renderer that starts a browser should keep it warm rather than spend the budget on startup.
- Reject what you do not understand, saying what is wrong, why, and — where
you can — how to put it right: an
errorwhen it is in a block, a diagnostic when it is in how you were configured. - Give every asset in a reply its own name. A repeat is refused rather than guessed at.
- Give an asset a bare filename. A name carrying
/, or.and..alone, is refused. - Stay off the network unless the job needs it. Keystone builds offline; if
yours can render without one, say so and let your compose example carry
network_mode: none. - Remain stateless. A reply may depend on the request and your own configuration, and on nothing else.
- Say when your answers change, with an
identity.
A whole hook
The handler the Dockerfile above runs: both operations, an asset chosen by format, and nothing kept between requests.
#!/usr/bin/env bash
set -euo pipefail
request="$(cat)"
case "${request}" in
*'"describe"'*)
printf '%s' '{"protocols":[1],"targets":["box"],"identity":"box-hook-1.0.0",'
printf '%s' '"formats":[["pdf","docx","odt"],["epub"]]}'
;;
*'"content":""'*)
printf '%s' '{"error":{"problem":"this block is empty, so there is nothing to draw"}}'
;;
*'"epub"'*)
svg="<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
printf '{"body":"","assets":[{"name":"box.svg","media_type":"image/svg+xml","data":"%s"}]}' \
"$(printf '%s' "${svg}" | base64 | tr -d '\n')"
;;
*)
printf '{"body":"","assets":[{"name":"box.png","media_type":"image/png","data":"%s"}]}' \
"$(render-png "${request}" | base64 | tr -d '\n')"
;;
esac
A real hook parses the JSON rather than matching on it, and renders something. Neither changes the shape.