Tyrtulia
A headless forum for small communities on shared Unix servers (tildes, pubnixes, club servers). It needs very few resources, between 60 and 70 MB of RAM and about 50 MB of disk space.
You need a client to take part: the terminal client Tyrtulia TUI or the Emacs client tyrtulia.el. Anyone can write their own client.
It is a modern, clean adaptation of BBJ (Bulletin Butter & Jelly), a textboard for tilde communities. It keeps BBJ's ideas (a small server that is easy to run, several text clients against one API, reading without an account, validation rules shared with clients), rebuilds its foundations on current standards and adds what BBJ was missing: notifications, real time, edit history, search and moderation. See the comparison with BBJ.
Table of contents
- Features
- Other uses
- Requirements
- Installation
- Getting started for administrators
- Clients
- Design principles
- Authentication and accounts
- Roles
- Configuration
- Deployment
- Administration CLI
- Behaviour
- Threads as wiki pages
- Validation
- Representations
- API reference
- Error catalog
- Comparison with BBJ
- Contributing
- License
Features
- Hypermedia REST API (HAL): clients only know
GET /and navigate by following links. - Passwordless authentication: Unix socket for local users, signed SSH challenge for remote access.
- Threads with linear replies and navigable conversations (
replyTo/replies). - Standard Markdown, with sanitized HTML returned alongside the source.
- Mentions, notifications and thread subscriptions.
- Full edit history with diffs and revert.
- Pinned and closed threads; soft deletion of threads and posts.
- Server-side full-text search, accent and case insensitive.
- Atom feeds for the forum and for each thread.
- Real-time notifications over Server-Sent Events (SSE).
- Moderation: roles, read-only users, bans, hidden posts, public moderation log and reports.
- Rate limiting per user and per IP.
- Discoverable validation through JSON Schema.
- Plain-text configuration (TOML), no admin endpoints.
Out of scope on purpose: images, attachments, categories, anonymous posting.
Other uses
Tyrtulia is a store of threaded Markdown documents with public history, search, notifications and feeds, where every process on the server is already identified as its Unix user. That makes it useful beyond a forum.
- Wiki and documentation. Community guides, FAQs or decision records, each one a thread whose first post is the page: public history with diffs and revert, full-text search, and the replies as its discussion page. See Threads as wiki pages.
- Shared announcements and notifications. Administrators post maintenance windows and changes in a pinned, closed thread; whoever watches it gets them in real time. Scripts, cron jobs and hooks can post through the socket with no credentials, and a mention (
@alice) notifies someone directly: a persistent, searchablewall. A login script can show unread notifications the same way. Automated posts need a regular account (UID at or above[accounts].min_uid) or an SSH key account, since system accounts are rejected. - Group blog. Each thread is an article and its replies are the comments. The forum's Atom feed is the subscription, and Tyrtulia Web can publish it on the web. Only people with an account can comment.
- Teaching platform. On a school or university server, students already have Unix accounts, so nobody signs up: the teacher moderates, each exercise or question is a thread, and the search keeps past answers at hand. Since the product is a hypermedia API discovered from
GET /, writing a client is also a good exercise in REST and HAL, in any language, against the same instance.
Everything is public to anyone who can read the instance: there are no private messages, so do not use it for secrets or personal data. The index has no categories; for unrelated purposes, run one instance each, which is cheap.
Requirements
- Linux or a BSD system, such as FreeBSD or OpenBSD (the Unix socket identity relies on
SO_PEERCREDon Linux andgetpeereid()on BSD). - Python 3.13 or newer (the system Python of Debian 13 and Ubuntu 26.04 works).
- SQLite 3 with FTS5.
- OpenSSH client (
ssh-keygen), used to verify signatures.
Installation
Tyrtulia is published on PyPI as tyrtulia. It installs the tyrtulia command (the server and the administration CLI). Install it in its own environment, owned by root and readable by the service user.
Install with
"tyrtulia<2". A 2.0.0 was published by mistake (the same code as 1.2.0) and cannot be withdrawn from PyPI yet.<2always gets the newest 1.x and never that release. The quotes keep the shell from reading<as a redirection.
With uv:
# The interpreter goes to /opt/python so the service user can read it
UV_PYTHON_INSTALL_DIR=/opt/python uv venv --python 3.13 /opt/tyrtulia
uv pip install --python /opt/tyrtulia/bin/python "tyrtulia<2"
ln -s /opt/tyrtulia/bin/tyrtulia /usr/local/bin/tyrtulia
With pip:
python3 -m venv /opt/tyrtulia
/opt/tyrtulia/bin/pip install "tyrtulia<2"
ln -s /opt/tyrtulia/bin/tyrtulia /usr/local/bin/tyrtulia
To have a look without installing anything: uvx --from "tyrtulia<2" tyrtulia --help.
Some dependencies are compiled when there is no binary wheel for the system:
- FreeBSD:
pkg install python314 py314-sqlite3 rust libev libuv c-ares, and setGEVENTSETUP_EMBED=0while installing so gevent uses the system libraries. - OpenBSD:
pkg_add python rust.
To upgrade, install the new version over the same environment (uv pip install --python /opt/tyrtulia/bin/python --upgrade "tyrtulia<2" or /opt/tyrtulia/bin/pip install --upgrade "tyrtulia<2"), run tyrtulia migrate as the service user and restart the service.
Getting started for administrators
A first instance on Linux with systemd, step by step. Run the commands as root. BSD notes follow each step.
-
Create the service user and its directories.
useradd --system --no-create-home --shell /usr/sbin/nologin tyrtulia install -d -o tyrtulia -m 0700 /var/lib/tyrtulia install -d /etc/tyrtulia
BSD:
pw useradd tyrtulia -d /nonexistent -s /usr/sbin/nologinon FreeBSD,useradd -d /var/empty -s /sbin/nologin _tyrtuliaon OpenBSD; the database goes to/var/db/tyrtulia. -
Write the configuration. List yourself in
adminswith your Unix name. Every key is described in Configuration.cat > /etc/tyrtulia/config.toml <<'EOF' name = "Forum of example.org" admins = ["alice"] moderators = [] EOF chown tyrtulia /etc/tyrtulia/config.toml
BSD: also set
[listen].socket = "/var/run/tyrtulia/tyrtulia.sock"and[storage].database = "/var/db/tyrtulia/tyrtulia.db". On FreeBSD the file usually lives in/usr/local/etc/tyrtulia/config.toml. -
Create the database.
sudo -u tyrtulia tyrtulia --config /etc/tyrtulia/config.toml migrate
-
Install and start the service.
curl -o /etc/systemd/system/tyrtulia.service https://git.andros.dev/andros/tyrtulia/raw/branch/main/contrib/systemd/tyrtulia.service systemctl daemon-reload systemctl enable --now tyrtulia
FreeBSD: download
https://git.andros.dev/andros/tyrtulia/raw/branch/main/contrib/freebsd/tyrtuliato/usr/local/etc/rc.d/tyrtulia(mode0555), thensysrc tyrtulia_enable=YESandservice tyrtulia start. OpenBSD: downloadhttps://git.andros.dev/andros/tyrtulia/raw/branch/main/contrib/openbsd/tyrtuliato/etc/rc.d/tyrtulia(mode0555), thenrcctl enable tyrtuliaandrcctl start tyrtulia. -
Check it as a normal user. The first request through the socket creates your account, with the
adminrole because you are listed inadmins:curl --unix-socket /run/tyrtulia/tyrtulia.sock http://localhost/users/me
Every local user can do the same: having a Unix account on the server is the invitation. With Tyrtulia TUI installed,
tyrtulia-tuiopens the forum straight away. -
Open it to the outside (optional). Enable HTTP on the loopback and put a web server in front of it with TLS. Event streams need buffering off.
[listen] http = "127.0.0.1:7099" trusted_proxies = ["127.0.0.1"]
location / { proxy_pass http://127.0.0.1:7099; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; }
Users of the server register their SSH keys through the socket and log in from anywhere with a signed challenge.
-
Invite people without a Unix account. Create an SSH key account with their public key:
sudo -u tyrtulia tyrtulia --config /etc/tyrtulia/config.toml account create bob --key bob.pub
-
Day to day.
- Change admins, moderators or limits in the configuration and apply it without downtime:
systemctl reload tyrtulia(service tyrtulia reloadorrcctl reload tyrtuliaon BSD). - Logs go to the journal (
journalctl -u tyrtulia) or to syslog on BSD. - Make backups of the database.
- Moderation happens through the API with the moderator links; the Administration CLI covers accounts, identities and the BBJ import.
- Change admins, moderators or limits in the configuration and apply it without downtime:
Clients
Tyrtulia is headless: people use it through a client.
- Tyrtulia TUI: the terminal client.
- tyrtulia.el: the Emacs client.
- Tyrtulia Web: the web viewer.
Any program that speaks HTTP can be a client: it only needs GET / and the links in the API reference.
Design principles
Modern standards are preferred over custom formats: HAL, RFC 9457, RFC 7396, RFC 6570, JSON Schema, SSE, UUIDv7 and ISO 8601.
| Topic | Decision |
|---|---|
| Discovery | Only GET / is known. Everything else is discovered through _links, using href, method, and optionally templated (RFC 6570), type and schema. _embedded holds embedded resources. |
| Permissions | Expressed as links. If you can edit, an edit link appears; if a thread is closed, reply disappears. Links that do not apply are omitted, never null. Clients never check roles. |
| Methods and status codes | Real HTTP methods (GET, POST, PATCH, PUT, DELETE), correct status codes and Location on creation. |
| Partial updates | PATCH with application/merge-patch+json (RFC 7396). |
| Errors | RFC 9457, application/problem+json, with a catalog of type values and useful links inside the error (for example, the diff in an edit conflict). title and detail are always in English. |
| Media type | Responses are application/vnd.tyrtulia.v1+json. Clients may send Accept with that type, application/json or */*; anything else returns 406 not-acceptable. |
| Pagination | Cursor based: clients follow next and prev, and never build cursors (they are opaque). Posts are paginated by number (after, before), so a client can ask only for what is new since the last post it saw. perPage defaults to 20, maximum 100. |
| Concurrency | Each post has a strong ETag per version ("v2"). A thread's ETag is the version of its first post, because the title is versioned with it. Editing a post body or a thread title requires If-Match; pinning or closing does not. Missing header: 428. Stale: 412. |
| Conventions | JSON in camelCase, dates in ISO 8601 UTC, identifiers are UUIDv7 and opaque to clients. postNumber and version are positions, not identifiers, and stay integers. Posts are numbered from 1. |
| Users in responses | The author is embedded in each resource (_embedded.author). |
| Contract | The contract is the link relations (rel) and the representations. Paths are an implementation detail. |
Authentication and accounts
There are no passwords. There are two ways in:
| Method | For | How |
|---|---|---|
| Unix socket | Users of the server itself | The server also listens on a Unix socket, and the operating system tells it which Unix user is connecting (SO_PEERCRED on Linux, getpeereid() on BSD). No token needed. |
| SSH key | Accounts created by the CLI, and Unix accounts connecting from outside | A login message that names the instance, the user and a challenge, signed with the private key (standard OpenSSH signature, ssh-keygen -Y sign, namespace tyrtulia). Returns a session token. |
-
The socket speaks exactly the same HTTP API: a resource does not depend on the transport.
-
The socket identity is guaranteed by the operating system and cannot be spoofed. System accounts (UID below
min_uid, 1000 by default) and the users listed inexcluded_users(nobodyby default, whose UID changes between systems) are excluded. If the server runs in a container that does not share the host users, the socket is not available and SSH is used. -
What is signed: the
messagereturned with each challenge, never the bare challenge:tyrtulia login origin: https://forum.example.org username: alice challenge: 3q2-7wXbG0yTuFq8aN1sKkz9Jm5vQe4Lr6Hc2Pd0Tbw
The origin is
[listen].public_urlor, when it is empty, the scheme and host of the request. Clients check that it is the instance they are talking to before signing, so a signature made for one instance (perhaps a malicious one) is useless on any other, and it is only valid for that user and that challenge. -
SSH challenges expire after 2 minutes and are single use. The response is the same whether the user exists or not, and a bad signature returns the same message in both cases, so account names are not leaked.
-
Accepted keys:
ssh-ed25519,ecdsa-sha2-*andrsa-sha2-*of 3072 bits or more. Each user manages their own keys (list, add, delete, with fingerprint and last use). On a shared server, a client can import the keys the user already has authorized for SSH. -
Session tokens are the only kind of token. They expire after 30 days without use or on logout. The server only stores their hash. They carry the
tyrtulia_ses_prefix so secret scanners can detect them. -
Banning revokes the user's sessions and blocks their SSH keys: they cannot log in (
403 banned), and any socket request receives403 banned.
Account types
There is no HTTP registration. The Unix user is the source of truth. There are exactly two kinds of account:
- Unix account: created automatically on a Unix user's first socket request of any kind (a
GETis enough), and bound to the pair UID + Unix name. - SSH key account: created by the operator with the CLI, together with its SSH key, for people without a user on the server. The CLI rejects any name that already exists as a Unix user.
Rules:
- An SSH key account can never be used through the socket.
- A Unix account can register SSH keys (from the socket) to connect from outside.
- Unix identity: on every socket request, the UID and the name must both match the account. If they do not (a UID reused after deleting a user, or a
usermod -l), the request is rejected with409 unix-identity-mismatchand the operator resolves it with the CLI. - Deactivated accounts: when a Unix user no longer exists, its account is deactivated: its sessions are revoked and it cannot log in, not even with SSH keys. Its posts remain, its profile still loads, and the user appears with
deactivated: true. - Name conflicts: if a Unix user is created later with the same name as an SSH key account, the socket rejects it (
409 username-conflict) until the operator renames the SSH key account with the CLI. Accounts are never merged and posts are never reassigned. - Names used before: a name an account gave up (by a rename) stays reserved, so a new Unix user with that name also gets
409 username-conflict. Roles in the configuration go by name, and this keeps them from passing to someone else. When the operator resolves a conflict withidentity resolve --rename-to, the name is released for the Unix user, unless it is listed inadminsormoderators: then the configuration has to change first.
On a tilde server, having a Unix account is the invitation.
Profile
Profiles are minimal:
about: free text in Markdown, public. Returned with its sanitized HTML (aboutHtml).email: optional and unverified. Only visible to the user and to moderators, so moderation can contact them. Tyrtulia never sends email.username: can only be changed on SSH key accounts, and only to a name that no account uses or has ever used, that does not exist as a Unix user and that is not listed inadminsormoderators(409 username-taken). A name used once is reserved forever, so a role from the configuration file never passes to someone else through a rename. After a rename, the old name returns404. On Unix accounts the username is always the Unix name. Mentions are stored by user id, so they keep working after a rename.
Roles
| Role | Can | Assigned by |
|---|---|---|
admin |
Everything a moderator can. Runs the instance (configuration file and CLI). | Configuration file (admins) |
moderator |
Edit, revert, hide, unhide and delete any post; edit, pin, close and delete threads; make users read-only; ban. | Configuration file (moderators) |
user |
Read and participate. | Default |
readonly |
Read only: just GET requests (notifications and SSE included) and logging out. Cannot interact in any way: post, edit, subscribe, report, mark notifications, change their profile or keys. All of that returns 403 readonly. Their notifications have no read state and no counter. |
A moderator, through the API |
banned |
Nothing. Every authenticated request returns 403 banned, including reads and socket requests. |
A moderator, through the API |
- Moderators act on any post, including posts by other moderators and admins. Only roles are protected: moderators cannot change the role of admins or other moderators (
403 cannot-moderate-staff). - Unbanning restores the previous role (
userorreadonly) and reactivates the SSH keys. - Anyone can read without authenticating; writing requires an account with the
user,moderatororadminrole.
Configuration
There are no administration endpoints. Whoever runs the server edits a plain-text TOML file with the admins, the moderators and the whole instance configuration. Access control is the file system's.
The file is read from /etc/tyrtulia/config.toml, unless --config or the TYRTULIA_CONFIG environment variable point elsewhere.
name = "Forum of example.org"
admins = ["alice", "bob"]
moderators = ["carol"]
[listen]
socket = "/run/tyrtulia/tyrtulia.sock" # enabled by default
http = "" # disabled; e.g. "127.0.0.1:7099"
trusted_proxies = ["127.0.0.1"] # real IP from X-Forwarded-For only from here
public_url = "" # e.g. "https://forum.example.org"; absolute URLs use it
[storage]
database = "/var/lib/tyrtulia/tyrtulia.db"
[accounts]
min_uid = 1000 # below this, system accounts: excluded from the socket
excluded_users = ["nobody"] # never identified through the socket
[rate_limit]
read_per_minute = 300
write_per_minute = 20
login_per_15_minutes = 10
max_sse_per_user = 5
- The Unix socket is enabled by default and HTTP is disabled. HTTP is only needed for access from outside the server, usually behind a web server.
public_urlis the public origin of the instance. Absolute URLs (errortype, feed ids and links) are built from it; when it is empty, from theHostof each request. Set it when HTTP is enabled.- Changes apply on restart or on
SIGHUP, without stopping the service.
Deployment
The service does not need root: it runs as a dedicated user that owns the configuration file and the database.
- Reference: native with systemd (Linux). Dedicated user;
RuntimeDirectory=tyrtuliawithRuntimeDirectoryMode=0755to create/run/tyrtuliawithout root;StateDirectory=tyrtuliafor/var/lib/tyrtulia; a socket with0666permissions so any local user can connect. The unit is incontrib/systemd/tyrtulia.service. - Native with rc.d (FreeBSD and OpenBSD). The scripts in
contrib/freebsd/tyrtuliaandcontrib/openbsd/tyrtuliarun the service as a dedicated user, create/var/run/tyrtuliawith mode0755and starttyrtulia serve --pidfile, soservice tyrtulia reloadorrcctl reload tyrtuliasendsSIGHUP. Set[listen].socket = "/var/run/tyrtulia/tyrtulia.sock"and keep the database under/var/db/tyrtulia. On FreeBSD, build gevent against the system libraries (pkg install libev libuv c-aresandGEVENTSETUP_EMBED=0). - The native modes are the only ones with Unix socket access.
- Private data on disk. The database holds emails, hidden and deleted posts, history and reports. Tyrtulia creates it with mode
0600(and fixes the mode of an existing one); the systemd unit addsStateDirectoryMode=0700andUMask=0077, and the rc.d scripts run withumask 077. - HTTP on a shared server. Local users can connect to
[listen].httpon127.0.0.1too, and from thereX-Forwarded-Forwould be believed. Let only the web server's user reach that port, for example withiptables -A OUTPUT -o lo -p tcp --dport 7099 -m owner ! --uid-owner www-data -j REJECTon Linux orblock return out quick on lo0 proto tcp to port 7099 user != wwwwith pf on the BSDs. compose.yaml: for a quick start, or for instances that only use SSH keys. Inside a container the host users are not visible, so the Unix socket is not available and[listen].httpmust be enabled.
Backups
Tyrtulia has no backup command: backups are manual. The database is a single SQLite file and can be copied safely while the service runs:
sqlite3 /var/lib/tyrtulia/tyrtulia.db ".backup /path/to/tyrtulia-backup.db"
Administration CLI
The CLI is the tool of whoever runs the instance (admins and operator are the same thing). It is not a forum client.
| Command | Purpose |
|---|---|
tyrtulia serve [--pidfile <file>] |
Start the server. --pidfile writes its PID, for rc.d scripts. |
tyrtulia migrate |
Apply database migrations. |
tyrtulia account create <name> --key <file> |
Create an SSH key account. Rejects Unix user names and reserved names. |
tyrtulia account rename <old> <new> |
Rename an SSH key account. Same rules as above. |
tyrtulia account add-key <name> --key <file> |
Add an SSH key to an account. |
tyrtulia identity resolve <name> |
Resolve a unix-identity-mismatch or a username-conflict. |
tyrtulia import-bbj <bbj.sqlite> [--trust-unix-names] |
Import a BBJ database. |
Importing from BBJ. Each BBJ user becomes an SSH key account without keys: they cannot log in until one is added with account add-key. If a BBJ username matches an existing Unix user, the import stops and lists the conflicts so the operator can decide case by case, because BBJ registration was open and the same name does not guarantee the same person. With --trust-unix-names, those users' posts go to the matching Unix accounts instead, for instances where the original BBJ was only used by the server's users. Anonymous posts are assigned to a fixed anonymous account, which can never log in and whose name is reserved. Identifiers are regenerated as UUIDv7, and BBJ's bracket format is converted to Markdown where possible.
The import is all or nothing. Before writing anything it checks every BBJ username and stops, listing all the conflicts at once, when a name:
| Conflict | Why | How to resolve it |
|---|---|---|
| Matches an existing Unix user | The same name does not guarantee the same person. | Rerun with --trust-unix-names if the BBJ users were the server's users. |
Is not a valid Tyrtulia username (^[a-z_][a-z0-9_-]{0,31}$, for example uppercase letters or spaces) |
Names are never changed silently. | Rename the user in a copy of the BBJ database and import that copy. |
| Is already used by a Tyrtulia account, or was used before (names are reserved forever) | Accounts are never merged and posts are never reassigned. | Rename the user in a copy of the BBJ database, or rename the existing SSH key account with account rename. |
--trust-unix-names only resolves the first case. Nothing is imported until the list of conflicts is empty.
Behaviour
Posts and conversations
- Markdown: CommonMark plus GFM tables, strikethrough and autolinks. The source is stored, and sanitized HTML (
bodyHtml) is always returned as well. Mentions are rendered as links to the user. - Linear replies that can point to the post they answer (
replyToPostNumber), as in Discourse. - Navigable conversations. Storage and the default view are linear, which keeps pagination by
postNumber. On top of that, each post exposes the conversation:replyTolinks to the post it answers, andreplieslinks to the posts that answer it. Clients walk the conversation in both directions by following links.repliesCount, to show "3 replies" without another request.- Optional
?view=treeon a thread's posts to receive the conversation already nested: up to 8 levels deep (deeper answers stay at the eighth level) and at most 500 replies per page; the linear view shows every post.
- Read state is a client concern. The server does not track what each user has read in threads. The client remembers the last
postNumberit saw in each thread, compares it withhighestPostNumberand asks for new posts with?after=.
Mentions, notifications and subscriptions
- Mentions with
@username, outside code and block quotes. At most 10 per post; more is rejected with422 too-many-mentions. Editing only notifies new mentions. - Notifications are an inbox with the kinds
reply,mention,watchedandreport(the last one only for moderators and admins). There is noquotekind: to refer to a post you reply to it, which produces areplynotification. - One notification per post and user. When a post matches several kinds, the highest priority wins:
mention>reply>watched. - Who is notified: never yourself. Read-only users are notified (without read state). Banned and deactivated users are never notified.
- Thread subscriptions have three levels. Creating a thread sets you to
watching; replying does not change your level; the default isnormal.watching: awatchednotification for every new post, plus replies and mentions.normal: only replies to your posts and mentions.muted: nothing from that thread, not even mentions.
- Notification excerpts are computed when read, and are empty if the post has been hidden or deleted.
- Notifications are kept indefinitely.
Editing and deletion
- Edit history with diffs and revert. Reverting creates a new version and never rewrites history. A thread's title is versioned with its first post: those revisions include
title, and reverting the first post also restores the title. - History is public, except for deleted posts (nobody sees it) and hidden posts (only moderators and the author).
- No edit time limit: the public history makes it unnecessary.
- Pinned threads: only moderators pin; pinned threads come first in the index, then the rest by creation date, newest first.
- Closed threads: only moderators can reply.
- Soft delete: a deleted post stays as a tombstone (number, author and dates, no body) so numbering is not broken.
- Deleting a thread: the author can only delete it if nobody else has replied (their own replies do not count). With replies, only a moderator can delete it; the author can still delete their first post, which becomes a tombstone, while the thread and its replies remain. A deleted thread returns
404to everyone except moderators.
Search and feeds
- Full-text search on the server over titles and bodies, accent and case insensitive. Query syntax: words,
"exact phrase",OR,NOTandprefix*; anything else returns400 invalid-query. Each result has ahighlight: an HTML snippet with the matches in<mark>, safe to insert. - Atom feeds for the forum and for each thread, discoverable through the
alternaterelation. Entry ids areurn:uuid:<id>and the content is the sanitized HTML. - Neither feeds nor search include hidden or deleted content.
Real time
- One SSE stream per user, carrying only their notifications (what you watch arrives as
watched). There are no per-thread or public streams. - Several connections per user are allowed (several clients at once), up to
max_sse_per_user. - Events carry the minimum plus links to the resource (see the example).
- Resume with
Last-Event-IDfor 24 hours; if the id is gone, the server sendsevent: reset. Heartbeat every 15 seconds.
Moderation
- Hidden posts carry a reason. The body and the reason are only visible to moderators and to the author; everyone else sees that the post is hidden.
- Public moderation log with action, reason, moderator and target. Every moderator action is logged, except a moderator editing, reverting or deleting their own posts or threads (that is ordinary authoring).
- Reports: a user sends an internal message to the moderators about a post, a thread or a user. It arrives as a
reportnotification to every moderator and admin (inbox and SSE). Reports are not public and do not appear in the moderation log. - Rate limiting:
GETandHEADcount as reads; everything else counts as a write. An open SSE connection counts as one read.POST /ssh-challengesandPOST /sessionsalso count in the login limit, per IP.- Through the socket, limits apply per user only; over HTTP, per user and per IP. The real IP is taken from
X-Forwarded-Foronly when the request comes fromtrusted_proxies. - Responses include the
RateLimitandRateLimit-Policyheaders, and429comes withRetry-After.
Threads as wiki pages
A thread also works as a collaborative wiki page. The first post is the page: its author and the moderators keep it up to date, with no edit time limit, and every change stays in the public history with diffs and revert. Pin it so it stays at the top of the index, close it to keep it clean, or leave it open so the replies act as its discussion page. Like any thread, it is found by the full-text search.
Validation
Every link that sends a body carries a schema property pointing to a JSON Schema (draft 2020-12) with the rules: required fields, lengths, formats and allowed values. Clients validate before sending without hard-coding the rules, and the server applies exactly the same ones (400 validation-failed otherwise).
| Field | Rule |
|---|---|
username |
^[a-z_][a-z0-9_-]{0,31}$ |
Thread title |
1 to 200 characters |
Post body |
1 to 32,000 characters |
about |
Up to 1,000 characters |
reason (edit, hide, ban, read-only) |
Up to 500 characters |
Report message |
1 to 2,000 characters |
SSH key title |
Up to 100 characters |
email |
Email format, up to 254 characters |
perPage |
1 to 100, default 20 |
| Text (title, body, about, reasons, report message, SSH key title) | No control characters except tab and line breaks, and no bidirectional overrides (U+202A–U+202E, U+2066–U+2069): they would drive the terminals of readers |
| Request body | Up to 256 KiB (413 request-too-large) |
Schemas: ssh-challenge, session, profile, ssh-key, thread, thread-update, subscription, post, post-update, notification-update, notifications-read, report, moderation.
Representations
Examples use these identifiers and users: thread 019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f, post #4 019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e; alice opened the thread, bob wrote post #4, carol is a moderator.
"Can write" below means an authenticated user with the user, moderator or admin role. Every link that changes something also requires being able to write: a read-only author, for example, gets no edit link.
Link objects
{"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/posts", "method": "POST", "schema": "/schemas/post"}
| Property | Required | Meaning |
|---|---|---|
href |
Yes | Target URL, relative to the instance. |
method |
Yes | HTTP method to use. |
templated |
No | true if href is an RFC 6570 template. |
type |
No | Media type of the target, when it is not the API's own (text/event-stream, application/atom+xml, text/x-diff). |
schema |
No | JSON Schema for the body the link expects. |
Root
Authenticated as alice, through the socket:
{
"name": "Forum of example.org",
"_links": {
"self": {"href": "/", "method": "GET"},
"latest": {"href": "/threads", "method": "GET"},
"watching": {"href": "/threads?filter=watching", "method": "GET"},
"createThread": {"href": "/threads", "method": "POST", "schema": "/schemas/thread"},
"search": {"href": "/search{?q,author,in}", "method": "GET", "templated": true},
"user": {"href": "/users/{username}", "method": "GET", "templated": true},
"me": {"href": "/users/me", "method": "GET"},
"notifications": {"href": "/notifications", "method": "GET"},
"events": {"href": "/events", "method": "GET", "type": "text/event-stream"},
"modlog": {"href": "/modlog", "method": "GET"},
"alternate": {"href": "/threads.atom", "method": "GET", "type": "application/atom+xml"}
}
}
rel |
Shown when |
|---|---|
self, latest, search, user, modlog, alternate |
Always |
watching, me, notifications, events |
Authenticated |
createThread |
Can write |
sshChallenge, login |
Not authenticated (never through the socket) |
UserSummary
Embedded in threads, posts, revisions, notifications and log entries.
{
"username": "bob",
"deactivated": false,
"_links": {"self": {"href": "/users/bob", "method": "GET"}}
}
User
{
"username": "bob",
"about": "Sysadmin. I like **SSE**.",
"aboutHtml": "<p>Sysadmin. I like <strong>SSE</strong>.</p>",
"role": "user",
"deactivated": false,
"postsCount": 42,
"threadsCount": 5,
"createdAt": "2026-03-02T10:15:00Z",
"_links": {
"self": {"href": "/users/bob", "method": "GET"},
"threads": {"href": "/threads?author=bob", "method": "GET"},
"report": {"href": "/reports", "method": "POST", "schema": "/schemas/report"}
}
}
rel |
Shown when |
|---|---|
self, threads |
Always |
report |
Can write, and it is not yourself |
ban or unban, readonly or unreadonly |
Moderator, and the user is not staff |
CurrentUser
The User of the authenticated user, plus private fields and links.
{
"username": "bob",
"about": "Sysadmin. I like **SSE**.",
"aboutHtml": "<p>Sysadmin. I like <strong>SSE</strong>.</p>",
"email": "bob@example.org",
"role": "user",
"deactivated": false,
"postsCount": 42,
"threadsCount": 5,
"unreadNotificationsCount": 3,
"createdAt": "2026-03-02T10:15:00Z",
"_links": {
"self": {"href": "/users/me", "method": "GET"},
"threads": {"href": "/threads?author=bob", "method": "GET"},
"edit": {"href": "/users/me", "method": "PATCH", "schema": "/schemas/profile"},
"sshKeys": {"href": "/users/me/ssh-keys", "method": "GET"},
"notifications": {"href": "/notifications", "method": "GET"},
"events": {"href": "/events", "method": "GET", "type": "text/event-stream"},
"logout": {"href": "/sessions/current", "method": "DELETE"}
}
}
rel |
Shown when |
|---|---|
self, threads, sshKeys, notifications, events |
Always |
edit |
Can write |
logout |
Authenticated with a session token (not through the socket) |
unreadNotificationsCount is absent for read-only users.
SshKey
{
"id": "019a2e10-77cc-7a1d-8b2e-3c4d5e6f7a8b",
"title": "laptop",
"fingerprint": "SHA256:3q2+7wXbG0yTuFq8aN1sKkz9Jm5vQe4Lr6Hc2Pd0Tbw",
"createdAt": "2026-10-01T09:00:00Z",
"lastUsedAt": "2026-10-18T08:12:44Z",
"_links": {
"delete": {"href": "/users/me/ssh-keys/019a2e10-77cc-7a1d-8b2e-3c4d5e6f7a8b", "method": "DELETE"}
}
}
delete is shown when the user can write. The sshKeys collection carries a create link (POST, schema ssh-key) under the same condition.
Thread
Detail view, as seen by alice (the author):
{
"id": "019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f",
"title": "SSE behind Nginx?",
"pinned": false,
"closed": false,
"postsCount": 12,
"highestPostNumber": 12,
"subscription": "watching",
"createdAt": "2026-10-17T18:02:11Z",
"lastPostedAt": "2026-10-18T09:40:00Z",
"_embedded": {
"author": {"username": "alice", "deactivated": false, "_links": {"self": {"href": "/users/alice", "method": "GET"}}},
"lastPoster": {"username": "carol", "deactivated": false, "_links": {"self": {"href": "/users/carol", "method": "GET"}}},
"firstPost": {"...": "Post"}
},
"_links": {
"self": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "GET"},
"posts": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/posts", "method": "GET"},
"reply": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/posts", "method": "POST", "schema": "/schemas/post"},
"edit": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "PATCH", "schema": "/schemas/thread-update"},
"subscribe": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/subscription", "method": "PUT", "schema": "/schemas/subscription"},
"report": {"href": "/reports", "method": "POST", "schema": "/schemas/report"},
"alternate": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f.atom", "method": "GET", "type": "application/atom+xml"}
}
}
- Response header:
ETag: "v2"(version of the first post). subscriptiononly for authenticated users.deletedAtonly for moderators, on deleted threads.- In collections, threads come without
_embedded.firstPost.
rel |
Shown when |
|---|---|
self, posts, alternate |
Always |
reply |
Can write, and the thread is open (or you are a moderator) |
edit |
Author or moderator |
pin, close |
Moderator (same href and method as edit, schema thread-update) |
delete |
Author while nobody else has replied, or moderator |
subscribe |
Can write |
report |
Can write, and it is not your thread |
Post
As seen by bob (the author):
{
"id": "019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e",
"postNumber": 4,
"replyToPostNumber": 2,
"body": "Add `X-Accel-Buffering: no` to the response. @alice",
"bodyHtml": "<p>Add <code>X-Accel-Buffering: no</code> to the response. <a href=\"/users/alice\">@alice</a></p>",
"version": 2,
"repliesCount": 1,
"hidden": false,
"createdAt": "2026-10-17T18:30:00Z",
"editedAt": "2026-10-17T18:50:00Z",
"_embedded": {
"author": {"username": "bob", "deactivated": false, "_links": {"self": {"href": "/users/bob", "method": "GET"}}}
},
"_links": {
"self": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
"up": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "GET"},
"replyTo": {"href": "/posts/019a2f4f-1c2d-7e3f-9a4b-5c6d7e8f9a0b", "method": "GET"},
"replies": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/replies", "method": "GET"},
"version-history": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions", "method": "GET"},
"edit": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "PATCH", "schema": "/schemas/post-update"},
"delete": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "DELETE"},
"report": {"href": "/reports", "method": "POST", "schema": "/schemas/report"}
}
}
- Response header:
ETag: "v2". editedAtis absent if the post was never edited;replyToPostNumberandreplyToare absent if it answers no post.- Hidden: everyone sees
hidden: true; only moderators and the author also receivebody,bodyHtmlandhiddenReason. - Deleted (tombstone): only
id,postNumber,createdAt,deletedAt,_embedded.authorand theselfanduplinks. - With
view=tree, each post carries its replies in_embedded.replies.
rel |
Shown when |
|---|---|
self, up, replies |
Always |
replyTo |
The post answers another post |
version-history |
Not deleted; if hidden, only to moderators and the author |
edit, delete |
Author or moderator, and not deleted |
hide or unhide |
Moderator (same href and method as edit) |
report |
Can write, and it is not your post |
Revision
{
"version": 1,
"body": "Add `X-Accel-Buffering: no`.",
"reason": "Initial version",
"createdAt": "2026-10-17T18:30:00Z",
"_embedded": {
"editor": {"username": "bob", "deactivated": false, "_links": {"self": {"href": "/users/bob", "method": "GET"}}}
},
"_links": {
"self": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/1", "method": "GET"},
"latest-version": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
"diff": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/1?format=diff", "method": "GET", "type": "text/x-diff"},
"revert": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/1/revert", "method": "POST"}
}
}
- Revisions of the first post also include
title. - In the
version-historycollection, revisions come withoutbody. revertis shown to the author and moderators, on any version except the current one.
Notification
{
"id": "019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a",
"kind": "mention",
"read": false,
"excerpt": "Add X-Accel-Buffering: no to the response. @alice",
"createdAt": "2026-10-17T18:30:00Z",
"_embedded": {
"actor": {"username": "bob", "deactivated": false, "_links": {"self": {"href": "/users/bob", "method": "GET"}}},
"thread": {"id": "019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "title": "SSE behind Nginx?", "_links": {"self": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "GET"}}}
},
"_links": {
"self": {"href": "/notifications/019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a", "method": "GET"},
"target": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
"markRead": {"href": "/notifications/019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a", "method": "PATCH", "schema": "/schemas/notification-update"}
}
}
_embedded.threadis present when the target belongs to a thread (a report about a user has none).- Read-only users get no
readand nomarkRead.
Collections
{
"_embedded": {"threads": [{"...": "Thread"}]},
"meta": {"perPage": 20, "hasNext": true, "hasPrevious": false},
"_links": {
"self": {"href": "/threads", "method": "GET"},
"next": {"href": "/threads?cursor=eyJrIjoiMjAyNi0xMC0xOFQwOTo0MDowMFoifQ", "method": "GET"}
}
}
- The embedded key is the plural of the item:
threads,posts,revisions,notifications,sshKeys,results,entries. nextandprevonly appear when there is a next or previous page.- The
notificationscollection addsmeta.unreadCountand amarkAllReadlink (POST, schemanotifications-read), except for read-only users. - The
sshKeyscollection addscreatewhen the user can write; thethreadscollection addscreateThreadunder the same condition.
Moderation log entry
{
"id": "019a2f70-44ee-7a6f-8d7a-8e9f0a1b2c3d",
"action": "postHidden",
"reason": "Off-topic flamewar",
"createdAt": "2026-10-18T11:00:00Z",
"_embedded": {
"moderator": {"username": "carol", "deactivated": false, "_links": {"self": {"href": "/users/carol", "method": "GET"}}}
},
"_links": {
"target": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"}
}
}
action is one of: postEdited, postReverted, postHidden, postUnhidden, postDeleted, threadPinned, threadUnpinned, threadClosed, threadReopened, threadDeleted, userBanned, userUnbanned, userMadeReadonly, userReadonlyRemoved.
Search result
{
"highlight": "Add <mark>X-Accel-Buffering</mark>: no to the response.",
"_embedded": {
"post": {"...": "Post"},
"thread": {"...": "Thread"}
}
}
Error
{
"type": "https://tyrtulia.example/errors/edit-conflict",
"title": "The post was modified by someone else",
"status": 412,
"detail": "Your copy is version 2, the current version is 3",
"instance": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e",
"_links": {
"latest-version": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
"diff": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/3?format=diff&from=2", "method": "GET", "type": "text/x-diff"}
}
}
API reference
Paths are the planned implementation, not the contract: clients discover them by following _links from GET /. The contract is the relation (rel) listed with each endpoint.
Conventions
API=https://tyrtulia.example # HTTP listener
TOKEN=tyrtulia_ses_xxxxxxxx # session token (HTTP only)
JSON='Content-Type: application/json'
# Example identifiers (UUIDv7)
THREAD=019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f
POST=019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e
NOTIF=019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a
KEY=019a2e10-77cc-7a1d-8b2e-3c4d5e6f7a8b
EVENT=019a2f61-33dd-7f5e-8c6f-7d8e9f0a1b2c
- Through the Unix socket no token is needed: replace
$APIwithhttp://localhostand add--unix-socket /run/tyrtulia/tyrtulia.sock. - Access levels: public (unauthenticated), user (authenticated), writer (can write:
user,moderatororadminrole), author (the author, or a moderator), moderator. author and moderator also require being able to write. - Errors common to every endpoint, not repeated below:
400 validation-failed,401 unauthenticated,403 forbidden,403 readonly(anything exceptGETand logout),403 banned,404 not-found,406 not-acceptable,413 request-too-largeand415 unsupported-media-type(requests with a body),429 rate-limited.
Root and authentication
Root
Entry point. Returns the forum name and the links available to whoever asks.
- Endpoint:
GET /· rel:self(root) · public - Parameters: none.
curl $API/
Response: 200 with a Root.
Request an SSH challenge
First step of remote login.
- Endpoint:
POST /ssh-challenges· rel:sshChallenge· public - Parameters:
username(body).
curl -X POST $API/ssh-challenges -H "$JSON" -d '{"username": "alice"}'
Response: 201 with {"challenge", "message", "namespace": "tyrtulia", "expiresAt"}. message is the text to sign (see what is signed). Same response whether the user exists or not. Expires in 2 minutes.
Log in
Sends the signed login message and receives a token.
- Endpoint:
POST /sessions· rel:login· public - Parameters:
username,challenge,signature(body). The signature is of the challenge'smessage.
REPLY=$(curl -s -X POST $API/ssh-challenges -H "$JSON" -d '{"username": "alice"}')
CHALLENGE=$(echo "$REPLY" | jq -r .challenge)
MESSAGE=$(echo "$REPLY" | jq -r .message)
echo "$MESSAGE" | grep -qx "origin: $API" || { echo "Not this instance: refusing to sign"; exit 1; }
SIG=$(printf %s "$MESSAGE" | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n tyrtulia)
curl -X POST $API/sessions -H "$JSON" \
-d "$(jq -n --arg c "$CHALLENGE" --arg s "$SIG" '{username:"alice", challenge:$c, signature:$s}')"
Response: 201 with {"token", "expiresAt"} and me and logout links. Errors: 401 invalid-signature, 403 banned.
Log out
Revokes the current token.
- Endpoint:
DELETE /sessions/current· rel:logout· user (session token only) - Parameters: none.
curl -X DELETE $API/sessions/current -H "Authorization: Bearer $TOKEN"
Response: 204.
Users
Get a profile
- Endpoint:
GET /users/{username}· rel:user(templated, on the root),self(on embedded users) · public - Parameters:
username(path).
curl $API/users/alice
Response: 200 with a User. Former usernames return 404.
Get my profile
- Endpoint:
GET /users/me· rel:me· user - Parameters: none.
curl $API/users/me -H "Authorization: Bearer $TOKEN"
Response: 200 with a CurrentUser.
Edit my profile
- Endpoint:
PATCH /users/me· rel:edit· writer - Parameters:
about,email,username(SSH key accounts only) (body, all optional).
curl -X PATCH $API/users/me -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/merge-patch+json' -d '{"about": "Hello from the tilde", "email": "alice@example.org"}'
Response: 200 with the CurrentUser. Errors: 409 username-taken, 403 fields-not-allowed (changing username on a Unix account).
List my SSH keys
- Endpoint:
GET /users/me/ssh-keys· rel:sshKeys· user - Parameters: none.
curl $API/users/me/ssh-keys -H "Authorization: Bearer $TOKEN"
Response: 200 with a collection of SshKey.
Add an SSH key
- Endpoint:
POST /users/me/ssh-keys· rel:create· writer - Parameters:
publicKey(required, anauthorized_keysline),title(optional; defaults to the key comment) (body).
curl -X POST $API/users/me/ssh-keys -H "Authorization: Bearer $TOKEN" -H "$JSON" \
-d "{\"publicKey\": \"$(cat ~/.ssh/id_ed25519.pub)\"}"
Response: 201 with the SshKey. Errors: 409 key-already-registered, 422 invalid-public-key.
Delete an SSH key
- Endpoint:
DELETE /users/me/ssh-keys/{keyId}· rel:delete· writer - Parameters:
keyId(path).
curl -X DELETE $API/users/me/ssh-keys/$KEY -H "Authorization: Bearer $TOKEN"
Response: 204.
Threads
List threads
Pinned threads first, then the rest by creation date, newest first.
- Endpoint:
GET /threads· rel:latest,threads,watching· public - Parameters:
author,filter=watching(requires a user),cursor,perPage(query).
curl "$API/threads?perPage=20"
Response: 200 with a collection of Thread.
Create a thread
Creates a thread with its first post. The author is set to watching.
- Endpoint:
POST /threads· rel:createThread· writer - Parameters:
title,body(body, Markdown).
curl -X POST $API/threads -H "Authorization: Bearer $TOKEN" -H "$JSON" \
-d '{"title": "SSE behind Nginx?", "body": "Does anyone have it working?"}'
Response: 201 with the Thread, including _embedded.firstPost, and a Location header. Error: 422 too-many-mentions.
Get a thread
- Endpoint:
GET /threads/{threadId}· rel:self,up· public - Parameters:
threadId(path).
curl $API/threads/$THREAD
Response: 200 with the Thread and its ETag. A deleted thread returns 404 not-found to everyone except moderators, who receive it with deletedAt.
Edit a thread
Changes the title (author) or pins and closes it (moderator).
- Endpoint:
PATCH /threads/{threadId}· rel:edit,pin,close· author / moderator depending on the field - Parameters:
If-Match(header, required whentitlechanges),title(author; creates a revision of the first post),reason(optional, withtitle),pinned,closed(moderator) (body).
curl -X PATCH $API/threads/$THREAD -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v2"' \
-H 'Content-Type: application/merge-patch+json' -d '{"title": "SSE behind Nginx: solved"}'
Response: 200 with the Thread and the new ETag. Errors: 403 fields-not-allowed, 428 precondition-required, 412 edit-conflict.
Delete a thread
Soft-deletes the whole thread.
- Endpoint:
DELETE /threads/{threadId}· rel:delete· author while nobody else has replied / moderator - Parameters:
threadId(path).
curl -X DELETE $API/threads/$THREAD -H "Authorization: Bearer $TOKEN"
Response: 204. Error: 409 thread-has-replies (author, with replies from other people).
Subscribe to a thread
- Endpoint:
PUT /threads/{threadId}/subscription· rel:subscribe· writer - Parameters:
level:watching,normalormuted(body).
curl -X PUT $API/threads/$THREAD/subscription -H "Authorization: Bearer $TOKEN" -H "$JSON" \
-d '{"level": "watching"}'
Response: 200 with {"level": "watching"}.
Posts
List a thread's posts
Linear view by default, or the nested conversation.
- Endpoint:
GET /threads/{threadId}/posts· rel:posts· public - Parameters:
after,before(post number),perPage,view=tree(query).
curl "$API/threads/$THREAD/posts?after=9"
Response: 200 with a collection of Post in ascending order. With view=tree, pagination applies to root posts (those that do not answer another post) and each one includes its replies nested in _embedded.replies, up to 8 levels deep (deeper answers stay at the eighth level) and at most 500 replies per page.
Reply
Adds a post to the thread. Notifies mentioned users, the author of the answered post and whoever watches the thread.
- Endpoint:
POST /threads/{threadId}/posts· rel:reply· writer - Parameters:
body(Markdown),replyToPostNumber(optional) (body).
curl -X POST $API/threads/$THREAD/posts -H "Authorization: Bearer $TOKEN" -H "$JSON" \
-d '{"body": "Add `X-Accel-Buffering: no`. @alice", "replyToPostNumber": 2}'
Response: 201 with the Post and a Location header. Errors: 409 thread-closed, 422 too-many-mentions.
Get a post
- Endpoint:
GET /posts/{postId}· rel:self,replyTo,target· public - Parameters:
postId(path).
curl -i $API/posts/$POST
Response: 200 with the Post and its ETag.
List replies to a post
- Endpoint:
GET /posts/{postId}/replies· rel:replies· public - Parameters:
cursor,perPage(query).
curl $API/posts/$POST/replies
Response: 200 with a collection of Post.
Edit or hide a post
Edits the body (creating a revision) or hides the post (moderator).
- Endpoint:
PATCH /posts/{postId}· rel:edit,hide,unhide· author / moderator to hide - Parameters:
If-Match(header, required whenbodychanges),body,reason,hidden,hiddenReason(body).
curl -X PATCH $API/posts/$POST -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v2"' \
-H 'Content-Type: application/merge-patch+json' -d '{"body": "Fixed text", "reason": "typo"}'
Response: 200 with the Post and the new ETag. Errors: 428 precondition-required, 412 edit-conflict (with latest-version and diff links), 422 too-many-mentions.
Delete a post
Soft delete: a tombstone remains. Deleting the first post does not delete the thread.
- Endpoint:
DELETE /posts/{postId}· rel:delete· author - Parameters:
postId(path).
curl -X DELETE $API/posts/$POST -H "Authorization: Bearer $TOKEN"
Response: 204.
History
List revisions
- Endpoint:
GET /posts/{postId}/revisions· rel:version-history· public (see restrictions) - Parameters:
postId(path),cursor,perPage(query).
curl $API/posts/$POST/revisions
Restrictions: no revision is served for a deleted post (404 not-found); for a hidden one, only to moderators and the author.
Response: 200 with a collection of Revision without body, newest first.
Get a revision or a diff
- Endpoint:
GET /posts/{postId}/revisions/{version}· rel:self,diff· public (see restrictions) - Parameters:
format=diff,from(base version, defaults to the previous one) (query).
curl "$API/posts/$POST/revisions/1?format=diff"
Restrictions: same as listing revisions.
Response: 200 with the Revision, or text/x-diff with a unified diff.
Revert to a revision
Creates a new version with the old content.
- Endpoint:
POST /posts/{postId}/revisions/{version}/revert· rel:revert· author - Parameters:
If-Match(header, required).
curl -X POST $API/posts/$POST/revisions/1/revert -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v3"'
Response: 200 with the Post and the new ETag. On the first post it also restores the title. Errors: 428 precondition-required, 412 edit-conflict.
Search
Search
- Endpoint:
GET /search· rel:search· public - Parameters:
q(required, see Search and feeds),author,in=title,cursor,perPage(query).
curl -G $API/search --data-urlencode 'q="sse nginx"'
Response: 200 with a collection of Search result by relevance. Error: 400 invalid-query.
Notifications
List notifications
- Endpoint:
GET /notifications· rel:notifications· user - Parameters:
filter=unread(not for read-only users),cursor,perPage(query).
curl "$API/notifications?filter=unread" -H "Authorization: Bearer $TOKEN"
Response: 200 with a collection of Notification, newest first, with meta.unreadCount (except for read-only users).
Mark a notification
- Endpoint:
PATCH /notifications/{notificationId}· rel:markRead· writer - Parameters:
read(body).
curl -X PATCH $API/notifications/$NOTIF -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/merge-patch+json' -d '{"read": true}'
Response: 200 with the Notification.
Mark all as read
- Endpoint:
POST /notifications/read-state· rel:markAllRead· writer - Parameters:
before(body): id of the newest notification seen; later ones stay unread.
curl -X POST $API/notifications/read-state -H "Authorization: Bearer $TOKEN" -H "$JSON" -d "{\"before\": \"$NOTIF\"}"
Response: 204.
Real time
User events
The user's only SSE stream: notification.created for every new notification.
- Endpoint:
GET /events· rel:events· user - Parameters:
Last-Event-ID(header, optional) to resume.
curl -N $API/events -H "Authorization: Bearer $TOKEN" -H "Last-Event-ID: $EVENT"
Response: 200 text/event-stream:
id: 019a2f61-33dd-7f5e-8c6f-7d8e9f0a1b2c
event: notification.created
data: {"kind":"mention","_links":{"self":{"href":"/notifications/019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a","method":"GET"},"target":{"href":"/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e","method":"GET"}}}
: heartbeat
event: reset if the Last-Event-ID is older than 24 hours: the client reloads through REST. Several connections per user, up to max_sse_per_user; beyond that, 429 too-many-connections. The server also keeps at most 1,000 open streams in total.
Feeds
Forum feed
- Endpoint:
GET /threads.atom· rel:alternate(on the root) · public - Parameters: none.
curl $API/threads.atom
Response: 200 application/atom+xml with the last 30 threads created, newest first, ETag and Last-Modified. Each entry is the thread's first post: replies do not change it, and its updated is the last edit of that post. Excludes deleted threads.
Thread feed
- Endpoint:
GET /threads/{threadId}.atom· rel:alternate(on the thread) · public - Parameters:
threadId(path).
curl $API/threads/$THREAD.atom
Response: 200 application/atom+xml with the last 30 posts, ETag and Last-Modified. Excludes hidden and deleted posts.
Moderation
Ban a user
Sets the role to banned, revokes their sessions and blocks their SSH keys.
- Endpoint:
PUT /users/{username}/ban· rel:ban· moderator - Parameters:
reason(body, required; public in the moderation log).
curl -X PUT $API/users/spammer/ban -H "Authorization: Bearer $TOKEN" -H "$JSON" -d '{"reason": "spam"}'
Response: 204. Error: 403 cannot-moderate-staff.
Unban a user
- Endpoint:
DELETE /users/{username}/ban· rel:unban· moderator - Parameters:
username(path).
curl -X DELETE $API/users/spammer/ban -H "Authorization: Bearer $TOKEN"
Response: 204. The user returns to the role they had before the ban (user or readonly) and their SSH keys are reactivated.
Make a user read-only
Sets the role to readonly: they can read but not interact in any way.
- Endpoint:
PUT /users/{username}/readonly· rel:readonly· moderator - Parameters:
reason(body, required; public in the moderation log).
curl -X PUT $API/users/troll/readonly -H "Authorization: Bearer $TOKEN" -H "$JSON" -d '{"reason": "flamewar"}'
Response: 204. Error: 403 cannot-moderate-staff.
Remove read-only
- Endpoint:
DELETE /users/{username}/readonly· rel:unreadonly· moderator - Parameters:
username(path).
curl -X DELETE $API/users/troll/readonly -H "Authorization: Bearer $TOKEN"
Response: 204. Back to the user role.
Moderation log
Public record of moderation actions, newest first.
- Endpoint:
GET /modlog· rel:modlog· public - Parameters:
cursor,perPage(query).
curl $API/modlog
Response: 200 with a collection of Moderation log entry.
Report
Sends an internal message to the moderators. Creates a report notification for every moderator and admin.
- Endpoint:
POST /reports· rel:report(on posts, threads and users) · writer - Parameters:
target(URL of the post, thread or user),message(body).
curl -X POST $API/reports -H "Authorization: Bearer $TOKEN" -H "$JSON" \
-d "{\"target\": \"/posts/$POST\", \"message\": \"Spam with links\"}"
Response: 202. Error: 422 invalid-target.
Validation
Get a schema
- Endpoint:
GET /schemas/{name}·schemaproperty of a link · public - Parameters:
name(path), one of the schemas.
curl $API/schemas/thread
Response: 200 application/schema+json with the JSON Schema.
Error catalog
All errors use Content-Type: application/problem+json (RFC 9457), with type under https://<instance>/errors/.
type |
Status | When |
|---|---|---|
validation-failed |
400 | The body or the query does not match the rules. |
invalid-query |
400 | Invalid search syntax. |
unauthenticated |
401 | Missing authentication or invalid token. |
invalid-signature |
401 | Wrong SSH signature (same message whether the user exists or not). |
forbidden |
403 | No permission for this action. |
readonly |
403 | Any request other than GET or logout from a read-only user. |
banned |
403 | Any request from a banned user. |
fields-not-allowed |
403 | A field in the PATCH is not allowed for you; nothing is applied. |
cannot-moderate-staff |
403 | Banning or making read-only a moderator or admin. |
not-found |
404 | Does not exist, or exists but you cannot see it. |
not-acceptable |
406 | Accept asks for an unsupported media type or API version. |
thread-closed |
409 | Replying to a closed thread without being a moderator. |
thread-has-replies |
409 | The author tries to delete a thread with replies from other people. |
key-already-registered |
409 | The SSH key is already registered. |
username-conflict |
409 | New Unix user with the name of an SSH key account, or with a name another account used before. |
username-taken |
409 | The username exists, existed, or is reserved in the configuration file. |
unix-identity-mismatch |
409 | The UID and Unix name do not match the account. |
edit-conflict |
412 | Stale If-Match. |
request-too-large |
413 | The body is larger than 256 KiB. |
unsupported-media-type |
415 | PATCH without application/merge-patch+json, or a body that is not JSON. |
too-many-mentions |
422 | More than 10 mentions in a post. |
invalid-public-key |
422 | SSH key with an unsupported format or type. |
invalid-target |
422 | The report target is not a valid post, thread or user. |
precondition-required |
428 | Missing If-Match when editing. |
rate-limited |
429 | Rate limit exceeded (with Retry-After). |
too-many-connections |
429 | More open SSE connections than max_sse_per_user. |
Comparison with BBJ
BBJ (Bulletin Butter & Jelly, a pun on bulletin board and PB&J) is a textboard for tilde communities, written in Python with CherryPy and SQLite, with a terminal client (urwid), an Emacs client and a minimal web interface. Tyrtulia keeps its spirit and rebuilds its foundations. These tables are informative: they summarise what Tyrtulia brings compared to BBJ.
Foundations
| Area | BBJ | Tyrtulia |
|---|---|---|
| API style | RPC: every call is POST /api/<endpoint> with a JSON body |
Hypermedia REST (HAL): clients only know GET / and follow links |
| Permissions | Clients ask the server (is_admin, edit_query) |
Expressed as links that appear or disappear |
| Authentication | Unsalted SHA-256 of the password sent on every request; the hash works as the password | No passwords: Unix socket (SO_PEERCRED or getpeereid()) and signed SSH challenge with revocable session tokens |
| Registration | Open, optionally with invitation codes | No HTTP registration: Unix users, or SSH key accounts created by the operator |
| Errors | 6 custom numeric codes | RFC 9457 with a catalog of types |
| Users in responses | Separate usermap |
_embedded.author |
| Versioning | None | Versioned media type |
| Pagination | Everything returned at once | Cursor based, posts by number |
| Concurrency | Last edit wins silently | ETag and If-Match |
| Identifiers and dates | UUID1, floating point timestamps | UUIDv7, ISO 8601 UTC |
| Text format | Custom bracket language ([bold: ...], [red: ...]) |
Standard Markdown |
| Validation | db_validate endpoint |
JSON Schema linked from each form |
| Configuration | config.json |
TOML |
Features
| Feature | BBJ | Tyrtulia |
|---|---|---|
| Anonymous reading | Yes | Yes |
| Anonymous posting | Yes | No |
| Reply to a specific post | >>N quotes in the text |
replyToPostNumber, with replyTo / replies links and an optional tree view |
| Mentions | No | Yes |
| Notifications | No (pending since 2017) | Inbox with reply, mention, watched and report |
| Thread subscriptions | No | watching, normal, muted |
| Real time | No (polling message_feed) |
SSE with resume (Last-Event-ID) |
| Edit history | No: edits overwrite the post | Full history with diffs and revert |
| Edit time limit | 24 hours | None (history makes it unnecessary) |
| Deletion | Deletes the row | Tombstone, numbering preserved |
| Pinned threads | Yes | Yes |
| Closed threads | No | Yes |
| Search | Done by each client over downloaded data | Server-side full text, accent and case insensitive |
| Feeds | No | Atom for the forum and each thread |
| Roles | Admin or not | admin, moderator, user, readonly, banned |
| Hidden posts | No | Yes, with a reason |
| Moderation log | No | Public |
| Reports | No | Internal message to moderators |
| Rate limiting | No | Per user and per IP |
| Profile | Bio, quip, color | About, private email |
| Import from BBJ | Not applicable | Administration CLI |
What stays the same
- A small server that is easy to run, built around SQLite.
- Several text clients (terminal, Emacs) against one documented API.
- Reading without an account.
- Validation rules shared between server and clients.
- The tilde and smolweb spirit: no images, no attachments, no categories.
Contributing
Contributions are welcome! Please see the contribution guidelines for instructions on how to submit issues or pull requests.
License
Tyrtulia is free software, released under the GNU General Public License v3.0 or later.
Metadata
Release files for tyrtulia 1.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| tyrtulia-1.4.0.tar.gz | 124.9 kB | Without provenance | Details for tyrtulia-1.4.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| tyrtulia-1.4.0-py3-none-any.whl | Python 3 | none | any | Details for tyrtulia-1.4.0-py3-none-any.whl |
Total release size: 253.8 kB
Release files / tyrtulia-1.4.0.tar.gz
| Download URL | tyrtulia-1.4.0.tar.gz |
|---|---|
| Size | 124.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3dbea7ffe67ccaa0356a55c6da528d07eeb49c0a93c30ce341bc5c4f442c200b
|
|
BLAKE2b-256 checksum How to use checksums |
58d3df7f788324cc7997fa5186883067ca01c55718a47f74fc5db3403c2d2e00
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.3 {"installer":{"name":"uv","version":"0.13.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / tyrtulia-1.4.0-py3-none-any.whl
| Download URL | tyrtulia-1.4.0-py3-none-any.whl |
|---|---|
| Size | 128.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b869946caca17503c419b579797e92880c91927fb5318fde7e4b63b764ddbbfb
|
|
BLAKE2b-256 checksum How to use checksums |
80e6bfc6c4902372df01846ab362cc1696712323623fccffeae2c6b482b4c0ba
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.3 {"installer":{"name":"uv","version":"0.13.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for tyrtulia 1.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.