Documentation
Quickstart
Clone Katagami, publish a template, and render your first PDF on your own machine.
This walkthrough starts from an empty machine and ends with a PDF on disk. Every command below is copy-paste runnable and was run against the stack it describes. Budget about ten minutes, most of which is the first image pull.
Before you start
You need Docker Engine 24 or newer with Compose v2.24 or newer (docker compose,
not docker-compose), plus git, curl, and
jq. You do
not need a Rust toolchain, a Typst install, or any cloud account. The quickstart
stack runs PostgreSQL and MinIO in containers alongside the released Katagami image.
1. Clone and start the stack
git clone https://github.com/BRO3886/katagami.git
cd katagami
docker compose up -d
The default Compose file pulls the exact Katagami release image (currently
ghcr.io/bro3886/katagami:0.0.2) instead of building from source, so expect a short
pull on first run. It starts three containers: the renderer on 127.0.0.1:8080,
PostgreSQL, and MinIO for S3-compatible object storage. The stack ships throwaway
credentials and an admin token of smoke-admin-token.
Metrics are deliberately not published to your host. A Prometheus service on the
Compose network can scrape http://renderer:9090/metrics; the public API returns
404 for /metrics.
Confirm the API is up:
curl http://127.0.0.1:8080/v1/templates
A fresh installation answers:
{"templates":[]}
Note
UseGET /health/ready to check whether a renderer can receive traffic. This catalog
read does not preload the catalog; the first render of a version reads its
stored pack through PostgreSQL and S3. If it does not answer, read the logs with
docker compose logs -f renderer.
2. Write a template
A template is ordinary Typst source. Katagami passes the request body’s data object
to your template as sys.inputs.at("data").
cat > invoice.typ <<'EOF'
#set page(width: 148mm, height: 105mm, margin: 12mm)
#set text(font: "Noto Sans", size: 11pt)
#text(size: 18pt, weight: "bold")[Invoice #sys.inputs.at("data").at("number")]
#v(6mm)
Billed to: #sys.inputs.at("data").at("customer")
Amount due: #sys.inputs.at("data").at("amount")
EOF
3. Build the publish payload
A published version carries its own schema, source, and fonts, so a render never
depends on files that happen to sit on the host. The payload sends each file
base64-encoded in a name-to-content map. source must contain main.typ, and
fonts must contain at least one decodable font. The repository ships Noto Sans
under assets/fonts/ for exactly this purpose.
jq -n \
--arg source "$(base64 < invoice.typ | tr -d '\n')" \
--arg font "$(base64 < assets/fonts/NotoSans-Regular.ttf | tr -d '\n')" \
'{
schema: {
type: "object",
additionalProperties: false,
required: ["number", "customer", "amount"],
properties: {
number: { type: "string" },
customer: { type: "string" },
amount: { type: "string" }
}
},
source: { "main.typ": $source },
fonts: { "NotoSans-Regular.ttf": $font }
}' > invoice-v1.json
The schema field is a JSON Schema. Katagami validates every render request against
it before Typst is invoked, so a caller that sends the wrong shape gets a clear 422
instead of a confusing compile error or a subtly wrong document.
Note
The map key is the font’s file name, but the name you use inside Typst is the font’s family name.NotoSans-Regular.ttf is written font: "Noto Sans" in the
template. Mismatching the two is the most common first-publish failure.
4. Create the template and publish version one
Template IDs and versions are created separately: first the ID, then an immutable version under it.
curl --fail-with-body -X POST http://127.0.0.1:8080/v1/admin/templates \
-H 'content-type: application/json' \
-H 'x-admin-token: smoke-admin-token' \
--data '{"id":"invoice"}'
curl --fail-with-body -X POST http://127.0.0.1:8080/v1/admin/templates/invoice/versions/v1 \
-H 'content-type: application/json' \
-H 'x-admin-token: smoke-admin-token' \
--data-binary @invoice-v1.json
Both return 201 Created with an empty body. Both require the x-admin-token
header; these two endpoints are the only way source ever enters the system.
5. Render a PDF
The render endpoint needs no admin token.
curl --fail-with-body -X POST http://127.0.0.1:8080/v1/templates/invoice/versions/v1/render \
-H 'content-type: application/json' \
--data '{"data":{"number":"INV-001","customer":"Ada Lovelace","amount":"1,240.00"}}' \
--output invoice.pdf
Open it with open invoice.pdf on macOS or xdg-open invoice.pdf on Linux. The
response is application/pdf bytes; nothing was written to object storage because
this request did not ask for persistence (the Compose dev stack has
ALLOW_PERSISTED_RESULTS=true, but a result is stored only when the request sends
"output":{"persist":true}).
Watch the guardrails work
Three requests that should fail, and the exact answers you get. Running these is the fastest way to understand what Katagami enforces.
Data that does not match the published schema is rejected before compilation:
curl -X POST http://127.0.0.1:8080/v1/templates/invoice/versions/v1/render \
-H 'content-type: application/json' \
--data '{"data":{"number":"INV-001"}}'
{"error":"schema validation failed"}
Republishing a version that already exists returns 409, because the version content
is immutable:
curl -o /dev/null -w '%{http_code}\n' \
-X POST http://127.0.0.1:8080/v1/admin/templates/invoice/versions/v1 \
-H 'content-type: application/json' \
-H 'x-admin-token: smoke-admin-token' \
--data-binary @invoice-v1.json
409
A publish without the right admin token returns 401:
{"error":"admin authentication required"}
Ship a change as a new version
Because versions never change in place, an update means publishing a new one. Callers
already pinned to v1 keep getting exactly the document they were getting before.
sed 's/Invoice #/Invoice · v2 #/' invoice.typ > invoice-v2.typ
jq --arg source "$(base64 < invoice-v2.typ | tr -d '\n')" \
'.source["main.typ"] = $source' invoice-v1.json > invoice-v2.json
curl --fail-with-body -X POST http://127.0.0.1:8080/v1/admin/templates/invoice/versions/v2 \
-H 'content-type: application/json' \
-H 'x-admin-token: smoke-admin-token' \
--data-binary @invoice-v2.json
curl http://127.0.0.1:8080/v1/templates
{"templates":[{"id":"invoice","versions":["v1","v2"]}]}
Render both versions and compare them. v1 still produces its original document.
Stop the stack
docker compose down --volumes
Dropping --volumes keeps the PostgreSQL and MinIO data, so your published templates
survive a restart.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
curl: (7) Failed to connect on 8080 |
The renderer is still pulling or failed to start | docker compose logs -f renderer |
401 admin authentication required |
Missing or wrong x-admin-token |
This quickstart uses the throwaway smoke-admin-token |
409 on publish |
That version is already published and cannot change | Publish under a new version name |
410 on render |
The version is retired | An administrator can unretire it if active capacity permits |
422 schema validation failed |
The request data does not match the published JSON Schema |
Compare against GET /v1/templates/{id} |
| PDF renders in the wrong font | Typst could not find that font family and used a fallback | Use the family name, not the file name |
413 |
Request data, an object, or a worker IPC frame exceeded a limit | See Operations and limits |
502 control plane unavailable |
PostgreSQL or object storage is unreachable | Check both containers and their credentials |
503 active template version capacity exceeded |
The active-version cap is full | Retire an active version or raise the active limit |
503 catalog retained-version byte capacity exceeded or incomplete |
Retained bytes are full or a legacy row has unknown bytes | Raise the retained-byte limit or cold-render the legacy version; retirement does not help |
Next steps
- API reference for the full publish payload, request assets, and every status code.
- Self-hosting to point Katagami at storage and a database you operate.
- System architecture for what happens between the request and the PDF.