A self-hosted, config-driven workbench for a doctorate.
A PhD is not a project plan. It is several clocks that disagree, a handful of formal gates whose consequences are categorical rather than gradual, a few papers in different states, a training component, and a pile of obligations nobody reminds you about until they are late. officina puts that on one page:
- what is due, as a short list you can actually read;
- what gates it — the administrative obligations whose failure mode is not delay but deletion, rejection or a closed window;
- what state each paper is in, as a state machine rather than a word count;
- where you left off — documents, commits, calendar, inbox; and
- a panel for your own agent or terminal, if you run one.
It reads YAML you write and files you already have. There is no database, no account, no telemetry, no build step, no CDN and no external requests other than the one optional source you must switch on yourself. It renders server side and it works offline.
officina holds no opinion about your discipline, your institution or your language. Everything specific to you lives in four config files.
Requires Docker with the Compose plugin. Nothing else.
git clone https://github.com/YOUR-USERNAME/officina.git
cd officina
cp .env.example .env
Create your config from the examples:
mkdir -p data
for f in config/*.example.yml; do
cp "$f" "data/$(basename "${f%.example.yml}").yml"
done
Edit .env and point the four path variables at your own files —
your writing workspace, a calendar export, a task export, a drop folder — and
set OFFICINA_UID/OFFICINA_GID to your own (id -u, id -g). Then edit
data/frame.yml: it is the only file you must fill in, and it is heavily
commented.
If you do not have some of those files yet, create empty stand-ins so the
bind mounts resolve, and disable the matching sources in data/sources.yml:
mkdir -p ro/workspace ro/handoff
printf '{}' > ro/calendar.json
printf '{}' > ro/tasks.json
printf '{}' > ro/status.json
Then:
docker compose up -d --build
officina is now on http://127.0.0.1:3013. Read Security posture before you expose it anywhere.
Four files, mounted read-write at /data inside the container. Each has an
annotated .example.yml in config/ — those files are the real
documentation; this section is only the map.
| File | What it holds |
|---|---|
frame.yml |
The doctorate itself: candidate, clocks, gaps, gates, outputs, fieldwork, coursework, milestones, dissemination, meetings, privacy. The only required file. |
sources.yml |
Where data comes from. One block per source, dispatched by type. |
rules.yml |
What the front page nags about, and how loudly. |
launchers.yml |
A short strip of links to the places you work. |
Config is re-read from disk about every 30 seconds. Edit a file, reload the page, see the change — no restart. A missing or malformed file degrades the section that needs it and leaves the rest of the page standing.
Some things worth knowing about frame.yml:
- Clocks may disagree, and that is the point. Your contract, your
admission and your project plan can all end on different days. Mark exactly
one clock
authoritative: true; it drives the big countdown, and the others are shown quietly beside it rather than reconciled away. - Gates are kept apart from progress because their consequences are
different in kind. Every gate carries a one-sentence
consequence. Write what actually happens, not what the status is. - Papers have states, not percentages. You define the vocabulary in
outputs.states; the engine only cares about position in that list. legacy_plan_endrecords what the original plan said. Keeping it makes the drift visible instead of quietly forgotten.
sources.yml names types, never products. Every collector returns
{"ok": true, ...} or {"ok": false, "error": "..."} and never raises, so a
missing file or an unreadable mount costs you one panel, not the page.
| Type | Does | Key settings |
|---|---|---|
json_file |
Reads a JSON document and reshapes it via a map block into the canonical fields the templates use. Your JSON keeps its own shape. |
path, map, max_age_minutes |
latex_tree |
Walks a manuscript tree: documents, word counts parsed from the source itself, and PDFs that are older than their source. No TeX installation required. | path, index_file, build_subdir |
git |
Recent commit activity for a working tree, via read-only plumbing commands. | path |
dir_count |
Counts files waiting in a folder. Counts only — no file names. | path, label |
link |
Not a collector: a labelled destination rendered as a panel, with an optional standing notice. Used for the agent/terminal pane. |
path, label, notice |
zotero_api |
The only source that touches the network, and it is off unless you set both environment variables. Otherwise it reports not_configured. |
library_id_env, api_key_env |
Container paths (/ro/...) are what sources.yml refers to; the host side of
each mount comes from .env. To drop a source, set enabled: false and remove
its line from docker-compose.yml.
A rule inspects the frame and, if it matches, contributes one line to the "Now" list. Rules are data — adding one never requires touching code.
- id: ethics-expiry
match:
collection: gates
status_in: [not_sent]
due_within_days: 120
text: "{title}"
why: "{consequence}"
urgent_if:
severity_in: [critical]
or_due_within_days: 60
priority: 95
match selects a collection and requires all its conditions. text and why
are templates whose {placeholders} are filled from the matched item. Lines
are sorted by urgency, then priority, then date, and truncated to
now.max_items.
Two conventions worth keeping:
whystates a consequence, not a status. "The legal basis expires and the material must be deleted" earns a place on the front page; "not yet submitted" does not.- Keep
max_itemssmall. A list you can read at a glance is the whole idea. A list of twenty is wallpaper.
The full list of match keys, urgency keys and placeholders is in
config/rules.example.yml.
No user-facing string is hardcoded. The application's own strings live in
locales/*.yml as flat key/value pairs and are looked up by key.
Set OFFICINA_LOCALE in .env to any file in locales/ (en, nb, ...).
Missing keys fall back to English per key, so a partial translation is useful
immediately and never renders a blank.
To add a language, copy locales/en.yml, translate the values, keep the keys,
and set OFFICINA_LOCALE to the new filename. Pull requests welcome.
Your own config is a separate matter: the strings in rules.yml,
frame.yml and launchers.yml are yours, and you write them in whatever
language you think in.
officina is designed to sit next to material it must not read. The rules below are enforced by the engine, not merely recommended.
privacy.exclude_paths — a list of globs in frame.yml. Every collector
that touches the filesystem calls a single guard before opening anything; a
matching file is never opened. Not indexed, not word-counted, not listed,
not read to check whether it changed. Add patterns generously — an extra
pattern costs nothing.
privacy.aggregate_only — collections named here expose counts and dates
only. Individual records are never rendered even if a source supplies them.
fieldwork is in this list by default: officina will tell you that three
interviews of a target of ten are done, and will not tell you anything about
any of them. Where no safe machine-readable source exists, the honest answer is
to type the number in yourself; that is what manual: true means.
Gaps are never explained. The gap schema deliberately has no reason
field, and with never_explain_gaps: true the renderer drops one even if you
added it. Leave, illness, a stopped clock — a gap is shown as a gap. Your
dashboard does not need to know why, and neither does anyone reading over your
shoulder.
No egress. There are no analytics, no error reporting, no fonts, no scripts and no stylesheets from anywhere but this repository. The only outbound request officina can make is the Zotero source, which is disabled until you provide credentials.
officina has no authentication of its own, and it links to a terminal. Treat it as a private-network application.
- The published port is bound to
127.0.0.1by default. Keep it that way. - If you want it on more than one device, put a reverse proxy with real authentication in front of it — forward-auth, SSO, mTLS, a VPN or a Tailscale-style overlay. Do not simply widen the port binding.
- The container runs as an unprivileged user with
no-new-privileges, and every data source is mounted read-only. Only/datais writable. - Serve it over TLS if it leaves the host.
- Your config is not secret from the application but it is personal:
data/,.envandconfig/*.ymlare gitignored. Keep them that way.
pip install fastapi "uvicorn[standard]" jinja2 pyyaml
OFFICINA_LOCALE=en uvicorn app.main:app --reload --port 8080
The application expects config at /data; bind-mount or symlink your config
directory there when running outside Docker.
| Endpoint | Purpose |
|---|---|
GET / |
The page. |
GET /api/state |
The same state object, as JSON. Useful for debugging a source or driving something else. |
GET /healthz |
{"ok": true}. |
Layout: app/config.py (config loading and the privacy guard), app/sources.py
(one collector per source type), app/engine.py (pure functions over the
frame, no I/O), app/main.py (routes and state assembly), plus templates/,
static/ and locales/.
Every section of the state is independently fallible: each is wrapped, and a
failure records {"ok": false, "error": ...} for that section alone. A broken
source must never produce a 500.
Copyright (C) 2026 officina contributors.
officina is free software: you may redistribute it and modify it under the terms of the GNU Affero General Public License, version 3 or (at your option) any later version, as published by the Free Software Foundation. See LICENSE for the full text.
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
The AGPL is deliberate: if you run a modified officina as a network service for other people, they are entitled to your modifications.
officina reads config, and the same config can be edited without a shell — by
the browser, or by an agent on your private network. Every write goes through
app/store.py, which is atomic (temp file + os.replace), snapshots the
previous version into data/.history/ (last 20), and preserves the comments
you wrote in the file.
GET /api/config/{frame|sources|rules|launchers}
PATCH /api/config/{name} {"path": "fieldwork.completed", "value": 4}
POST /api/config/{name}/append {"path": "milestones", "value": {...}}
GET /api/notes list note ids
GET /api/notes/{id}
PUT /api/notes/{id} {"markdown": "..."}
path is dotted and may index lists: outputs.items.2.state.
One thing is immutable: frame.privacy. The exclusion globs, the
aggregate-only list and the never-explain-gaps flag are the guarantees the
rest of the code enforces. A client that could rewrite them could switch off
every protection by setting a value, so the API refuses — change them by
editing the file yourself, deliberately.
There is no authentication on these routes. officina expects to sit behind a reverse proxy that authenticates, on a private network. Do not publish the port.
