LucidPlan
free and open project management for everyone

lucidplan.svg scheme-guile.svg gnu-guix.svg maak.svg hygguile.svg gnu-artanis.svg license-agpl3+.svg license-fdl13+.svg jointhefreeworld-org.svg CI-Byggsteg%20%7C%20Woodpecker-2ea44f.svg

free and open project management for everyone

LucidPlan is a project management system for teams of all sizes and all kinds of work: a multi-project workspace, a priority-ordered backlog, and drag-and-drop kanban boards, kept deliberately simple and hackable end to end in Scheme.

It's a flexible system made to simplify everyone's life, accessible to all kinds of users – and free software (free as in freedom), licensed under the GNU Affero General Public License v3 or newer.

lucidplan-showcase.gif

Figure 1: The front page, the projects dashboard, a card dragged across a board, the backlog with a ticket opened, and the same board in the dark theme.

If you like my work, please support me by buying me a cup of coffee β˜• so I can continue with a lot of motivation.

Table of Contents

Try the public instance at lucidplan.jointhefreeworld.org.

This is both the project's README and its manual: the two documents were merged into one, so there is a single place to read and a single place to keep in step. The published HTML manual at https://jointhefreeworld.org/manuals/lucidplan/index.html is this file exported.


What LucidPlan is

LucidPlan enhances the use of Agile work methodologies in teams thanks to its fast-paced, no-nonsense workflows and deep customizability, also thanks to being written in Lisp.

This tool is the fruit of years of experience using bloated proprietary systems like Jira and Trello, and experiencing the insane frustrations they bring.

Who it is for

  • Small teams that want a board and a backlog today, not a six-week rollout.
  • Dozens of projects at once: one workspace, one dashboard, one search box.
  • Lisp people, who can make the thing behave differently by writing a few lines of Scheme instead of filing a feature request.
  • Self-hosters and freedom-lovers, who want their team's work on their own machine, under their own licence, without an account anywhere else.

What it is built with

Built with Guile Scheme and GNU Artanis, SQLite, Olive CSS and some small sprinkles of vanilla JavaScript.


Features

LucidPlan provides a comprehensive suite of tools to keep your projects organized:

  • Multi-Project Workspaces: Easily manage dozens of repositories or projects from a single unified dashboard. Features a quick-access "go to" dropdown and search functionality to navigate between contexts instantly.
  • Versatile Backlog Management: View all your project tasks in a clean, list-based backlog with priority drag and drop. Tickets have different types (Task, Bug, Story, Spike, Time Box), configurable statuses, org-mode syntax descriptions with links and rich content, and easy editing/deletion.
  • Kanban Boards: Visualize your workflow with built-in Kanban boards. Effortlessly track tickets and drag and drop as they move through columns like To-do, Work in Progress, and In Review.
  • Clean and Distraction-Free UI: A minimalist and accessible interface built with Olive CSS, featuring global search, quick project creation, and a toggleable light/dark mode to suit your preferences.
  • Granular User Management: Add, edit, and manage team members with ease. Permissions are handled using native Lisp lists (e.g., `(lucidplan:ticket-create lucidplan:project-delete)), allowing for incredibly fine-grained, programmatic access control, effectively providing an easy to use ACL with which you can control exactly what each user can/cannot do.
  • Org mode support: Titles and descriptions can be written using Org mode syntax, which enables more advanced media. This Org text gets automatically rendered to attractive HTML.
  • Flexible statuses: the statuses a ticket moves through are configuration, not code – rename them, drop the ones your team does not use, add your own, or give a single project its own board.
  • Estimates: every ticket can be estimated twice over – in story points, the numbers a team burns down, and in t-shirt sizes, which is what a first pass through a backlog is usually written in. Both are optional and both are changed from the ticket itself, so an unestimated ticket reads as one nobody has looked at yet rather than as one worth nothing.
  • Threaded discussion: a ticket carries a conversation, not a comment box. A reply is drawn under what it answers, indented by its depth, so a thread reads the way the discussion went; a comment that is removed keeps its place in that thread instead of taking the replies to it down with it.
  • Attachments: files are carried by the ticket they belong to, uploaded from its page and downloaded through the application, which checks that the caller named a file of the ticket in the URL. Nothing a browser sends is ever used as a path on disk.
  • An audit trail and versions: every write to a ticket is recorded – who did it, when, the field, the value before and the value after – and every change of content is kept as a numbered version that can be read back and restored. A version is never rewritten, so restoring an old one adds a new version whose content happens to be the old content.
  • Moving a ticket between projects: a ticket filed in the wrong place is one menu away from the right one. Its discussion, its files and its assignment move with it, and its history stays with it too, because the trail names the ticket rather than the project it was in.
  • Email notifications: a new ticket is announced to everyone taking part in the project.
  • A REST API: /api/v1 hands tickets and projects to your scripts, with the same permissions the web interface uses.
  • Fully Customizable: Everything an instance says about itself – its name, its description, its footer, the address mail is sent from – is configuration, and the behaviour and content of everything else can be hacked with simple Lisp (Scheme) code.
  • Cross-platform: Runs anywhere that Guile Scheme can run, and exposes web endpoints, and is thus accessible from computers, phones, etc.
  • Free Software: Contribute to its development or extend it with your own custom features.

Two ways to start

Just want to look around? Browse the public instance – reading needs no account.
Want your own instance? Quick start, then Running LucidPlan yourself.

Using LucidPlan

Backlog Management

LucidPlan provides several ways to organize and work with tickets directly from the web interface. Ticket management is designed around fast, simple interactions.

Tickets in the backlog can be reordered (in terms of priority) using native browser drag-and-drop. A rearrangement is written as the tickets' priorities, so the grip is offered while the backlog is sorted by priority – Priority - Descending or Priority - Ascending – and each direction writes the numbers that keep the rows where they were put. Under any other sort the row would slide back to its old place on the next visit, so no grip is offered there; pick a priority sort to reorder.

Backlog tickets can be expanded and collapsed without navigating away from the page, singly or all at once with Expand All and Collapse All.

Each ticket contains an expandable details section containing information such as:

  • description;
  • status;
  • type;
  • priority;
  • story-point and t-shirt-size estimates;
  • assignee;
  • project/ticket identifier;
  • edit and delete actions.

The backlog also carries a one-glance summary of the project – how many tickets are open, done and rejected, how many there are in total, and a completion bar – plus a sort menu and a search box.

Kanban Management

The Kanban board also provides a drag-and-drop interaction. Dragging a ticket onto another Kanban column changes its status.

For example, a ticket can be moved from:

To-do β†’ Work in Progress β†’ In Review β†’ Done

Everything a ticket carries

Opening a ticket shows the ticket itself and, under it, everything the work on it has gathered: its estimates, its discussion, its files, what has happened to it and the versions of what it said. All of it is written by the same permission the ticket is: lucidplan:ticket-create.

Estimates

A ticket is estimated twice over, because the two answer different questions:

  • story points – the numbers a team burns down, taken from a ladder (ticket-points, by default 0, 1, 2, 3, 5, 8, 13, 21 and 34);
  • t-shirt sizes – XS, S, M, L, XL and XXL, which is what a first pass through a backlog is usually written in.

Both are optional, and an unestimated ticket is a ticket nobody has looked at yet rather than a ticket worth zero: an unestimate is stored as nothing at all, never as 0, and the ticket reads as unestimated until somebody says otherwise.

Both are changed in place, from the ticket's own page, by the same controls the status and the type are changed with – and both can be cleared again, which is the first row of each menu. They are also part of the ticket's edit form, and they are part of what a version snapshots (see Activity and versions).

The two ladders are ticket-points and ticket-sizes in lib/lucidplan/domain.scm; the menus a page offers and the endpoints behind them are both built from those two lists, so a team that estimates in powers of two edits one line and the whole instance follows. Whatever is not on the ladder is refused rather than stored.

Discussion

A ticket carries a conversation, and the conversation is a thread: any comment can be answered, and the answer is drawn under what it answers, indented by its depth, so a discussion reads in the shape it happened in rather than as one long list.

Comment bodies are written in Org mode syntax, exactly as titles and descriptions are, and are rendered to HTML when the page is drawn.

Removing a comment is a soft removal, and that is deliberate: a reply whose parent disappeared would lose its place in the thread, so the removed comment stays where it is as an empty node – shown as [removed] – and the replies to it keep hanging from it. A comment may be removed by whoever wrote it, or by anyone holding lucidplan:ticket-delete, and the removal is recorded in the ticket's history either way.

Attachments

Files are uploaded from the ticket's own page and listed there with the name they were uploaded under and their size. A file can be downloaded and removed again by anyone who may edit the ticket.

What a browser sends as a filename is treated as data and never as a path: the bytes are stored under a name the application generates itself, and the submitted name is kept in the database and cleaned before it is written into a download header. A file therefore cannot escape the directory it is kept in, however it was named.

Downloads are answered by the application rather than by the static file server, so the attachment id has to name a file of the ticket in the URL: a file cannot be fetched by guessing a name in the upload directory.

Where the files live and how large one may be are configuration – attachment-directory (by default var/attachments, created on the first upload) and attachment-max-bytes (by default 5 MiB, the same ceiling Artanis puts on a request body). The directory is deliberately outside pub/, so it is never handed out as a static file. An instance that carries attachments has something to back up besides lucidplan.db: the two are written by different writes and are only together a complete record. An instance that does not want files keeps the directory empty, and only that loss.

Activity and versions

Every write the application makes to a ticket leaves a row in the ticket's activity: who did it, when, and – when it was a change to a field – the field, the value before it and the value after it. Creating, editing, assigning, commenting, attaching, restoring and moving are all in there, one row per field that actually changed, so a form submitted without a change writes no history at all. The trail is append-only: nothing in the application updates or removes a row, because a history that can be rewritten is not an audit trail.

What is watched is one list – ticket-tracked-fields in lib/lucidplan/domain.scm – so the trail and the versions cannot quietly disagree about what a ticket is made of. It holds the title, the description, the status, the type, the priority and both estimates. The ticket's "last edited" stamp is deliberately not on it: it moves on every write and says nothing about what the ticket means.

Alongside the trail the ticket keeps its versions: every time its content changes, the content it now has is written down as a numbered snapshot, and no snapshot is ever rewritten. Opening a version shows what changed between it and the one before it, and restoring an old one is an ordinary edit that happens to write the old content – so the versions in between stay where they are, and the history says a restore happened rather than pretending it never did. Comments and attachments are not snapshotted: they are records of their own, and the trail already says when they were added.

Moving a ticket between projects

A ticket filed in the wrong project is moved from its own page: the ticket offers the other projects, and choosing one moves it in a single step.

What follows the ticket is everything that belongs to it rather than to the project: its discussion, its files and its assignment. What stays is its history – the trail and the versions name the ticket, not the project it was in, so they are read back unchanged and the ticket arrives with its whole past.

Its number does not follow it. Ticket numbers count within a project, so the destination numbers the moved ticket as its own next one, and the page you land on is the ticket's new address. A status the destination's board does not name needs no thought either: such a ticket is treated as open, so it appears on the new board and can be dragged onto a status that exists there.

Keyboard Accessibility

Ticket and project lists are fully keyboard accessible using Tab navigation with Enter or Space for toggling visibility. The UI aims to be accessible to all kinds of users and usage patterns.

Practical usage tips

Using LucidPlan is made to be easy and simple; here are some tips that might help you out.

  • In any title or description field you can use the Org mode syntax (like in the Emacs Org mode) and LucidPlan will automatically convert your markup to pretty HTML.
  • The theme button in the header switches between light and dark, and remembers your choice.
  • The go to menu in the header jumps straight to any project's board.
  • Drag a ticket by its grip (the little dotted handle), never by the card itself, so that selecting the text of a ticket still means selecting it.

How the pages treat a reader who is not signed in

LucidPlan is readable before it is writable. A signed-out reader can browse the front page, the about page, every project's backlog and board, and each ticket – but every page that exists to submit or change something is behind the permission that change actually needs, and turns the reader away before they fill in a form that could only ever refuse them.

The pages that create or edit something ask for the same permission their write does:

  • creating or editing a project – lucidplan:project-create;
  • creating or editing a ticket – lucidplan:ticket-create;
  • managing users – lucidplan:modify-permissions.

The small inline controls on a ticket (its status, its type and its two estimates) are shown to exactly the readers the endpoint behind them would accept, so a reader who holds the permission sees the control and a reader who does not sees the read-only badge. The same holds for the rest of a ticket's own page: the comment box, the upload form, the remove controls, the restore buttons and the menu that moves the ticket to another project are all offered by the permission their write needs, lucidplan:ticket-create – except for removing somebody else's comment, which is offered only to a reader holding lucidplan:ticket-delete, the permission the endpoint checks as well.

What the mail service is asked to send

An instance that has been given a pingwing address (pingwing-url and its two credentials, see Configuring the instance) writes to the people of a project when a ticket is created in it – the news that a board moved is worth having even when nobody is looking at it.

Who hears it is everyone who takes part in the project: every user the project has assigned a ticket to, together with the ticket's own assignee and the person who opened it. LucidPlan keeps no membership list, so participation is read from the work itself; a user with no email address is passed over, and an address is written to once however many times it comes up.

The message is pingwing's lucidplan-new-ticket template, filled with the project, the ticket's number and title, its type, its status as the project's own board names it, its priority, the assignee and the author, the description, and a link to the ticket on site-url. The subject is the instance's own mail-subject-prefix and then New ticket in <project>; the sender is mail-sender-name and mail-sender-address.

Nothing about this can fail a write: a mail service that is unreachable, or that refuses the task, is tried again a few times (mail-retry-attempts, mail-retry-delay) and then given up on with a line in the log that repeats what the service said, and the ticket is already saved either way. A message counts as sent only when the service answers that it took it: a request that arrived and was refused is a message that did not go, and is treated as one.


Running LucidPlan yourself

If you are thinking of running LucidPlan yourself and hosting it on your own, this section walks through it from an empty machine to a working instance with a first user and their team.

Quick start (the impatient path)

If you already have GNU Guix, this is the whole thing:

git clone https://codeberg.org/jjba23/lucidplan.git
cd lucidplan
guix shell -m manifest.scm            # every dependency, pinned
maak init-db                          # create the database and migrate it
maak dev                              # hot-reloading server on localhost:50025

Then seed yourself a first account (Creating the first super-user has the details) and sign in at http://localhost:50025/login. maak --list shows every other task.

Prerequisites

LucidPlan is built with GNU Guile and GNU Artanis, and the project is developed and deployed with GNU Guix. Guix is the easy way in: the repository ships a manifest.scm that pins every dependency, including the Scheme libraries LucidPlan is built on (hygguile, kracht, scriba, bewaking and orgwebalchemy).

Make sure you have the Guix package manager, whether as a standalone distro (Guix System) or on top of any other GNU/Linux distribution.

If you want to run without Guix, read manifest.scm anyway: it lists exactly the packages you need. In some systems and situations you might also need to set a locale variable, like LC_ALL="en_US.utf8".

Getting the code and entering the environment

git clone https://codeberg.org/jjba23/lucidplan.git
cd lucidplan
# Open a shell with every dependency the project pins:
guix shell -m manifest.scm
# ...or, to build from the exact Guix revision the project is tested against:
guix time-machine -C channels.scm -- shell -m manifest.scm

Everything below is meant to be run inside that shell. The tasks themselves are declared in maak.scm and run with maak; maak --list shows them all.

The theme – the Lucida faces, the web fonts, the icon face and the stylesheets that describe them – is committed under pub/ and served by the instance, so a fresh clone needs no fetching to run. maak fetch-assets refreshes those files from their sources when you want them current.

Configuring the instance

An instance keeps its private configuration in a module called (lucidplan env), which LucidPlan loads at start-up from a file named env.scm in the root of the project. That file is per-deployment and must not be committed (it is already in .gitignore). Create it:

(define-module (lucidplan env))

;; -- Secrets: unique to this deployment ------------------------------

;; Encrypts the session token. Generate a fresh UUID for each key.
(define-public encryption-key "a2ebe4c5-4a78-4490-af83-cfb05a285bf9")
(define-public signing-key "8d3a5bcc-973e-4f80-aa3d-33be33d6631c")

;; -- Mail: how password resets and invites are delivered ---------------
;; Pingwing is a mail and notification service written in Guile Scheme,
;; also part of the jointhefreeworld project. Find it at
;; https://codeberg.org/jjba23/pingwing -- without it, invites and
;; password resets cannot be delivered.
(define-public pingwing-url "https://pingwing.example.org")
(define-public pingwing-basic-auth-user "my-user")
(define-public pingwing-basic-auth-password "my-password")

;; -- Site: what this instance says about itself ------------------------
;; Every value below is optional; omit any line to keep LucidPlan's
;; default. See the table further down for the whole list.
(define-public site-name "LucidPlan")
(define-public site-url "https://lucidplan.example.org")
(define-public contact-email "admin@example.org")
(define-public copyright-holder "Your Name or Organisation")

Every key above is read by (lucidplan config) at start-up: a value defined in the env module wins, and anything you leave out falls back to the default that ships with LucidPlan. See Instance configuration keys for the full list.

If you run LucidPlan locally, it is recommended to use localhost rather than 127.0.0.1, as this works better with the authentication, authorization and other cookies.

Instance configuration keys

All of these may be set in env.scm as define-public values of the same name.

;; What the instance is called and how it introduces itself
site-name              ; "LucidPlan"
site-tagline           ; "free and open project management for everyone"
site-description       ; one sentence, used as the meta description and in link previews
site-keywords          ; a list of strings, used as the page keywords
site-url               ; canonical base address, e.g. "https://lucidplan.example.org"
site-logo              ; link-preview image: an absolute URL, or a path on this instance
site-language          ; the <html lang="..."> value, e.g. "en"
asset-url              ; host this instance's own stylesheets and fonts are served from
analytics-endpoint     ; where the page-view script reports, or "" for no analytics
analytics-interval     ; how often it reports while a reader stays on the page, in ms

;; Who to credit and how to reach them
copyright-holder
contact-email
source-url             ; link to this instance's source code
manual-url             ; link to this instance's documentation
donate-url
social-links           ; a list of (label . url) pairs shown on the about page

;; What the about page says (Org-mode text)
about-org

;; Mail
mail-sender-name       ; "No Reply - LucidPlan"
mail-sender-address    ; the address notifications are sent from
mail-subject-prefix    ; e.g. "LucidPlan - "
mail-retry-attempts    ; how many times to try handing a message over
mail-retry-delay       ; seconds to wait before the first retry (doubles each try)

;; Accounts and passwords
password-reset-validity  ; how long a reset link stays good, in seconds
password-min-score       ; accepted password strength, 0 (very weak) .. 4 (very strong)

;; Files a ticket carries
attachment-directory     ; where uploaded files are kept, e.g. "var/attachments"
attachment-max-bytes     ; the largest one accepted, in bytes (5 MiB by default)

;; The languages the interface offers, as (catalog-name . name-in-that-language)
locales

;; What a ticket moves through: one entry per status, and the order below is
;; the order the board and the menus show. See "Renaming or changing the
;; ticket statuses".
ticket-statuses        ; ((value label icon glyph bucket) ...)

;; What the front page says about the software: the heading and line over the
;; feature grid, the cards themselves, and the heading and line over the
;; little board it draws. Each feature is (icon label text).
landing-features-title
landing-features-intro
landing-features       ; ((icon label text) ...)
landing-workflow-title
landing-workflow-intro

Renaming or changing the ticket statuses

Which statuses a ticket moves through is configuration, not code: an instance that does not use every status drops the ones it does not need, gives the ones it keeps the names its team says, and adds any that are missing.

The key is ticket-statuses, a list of entries. Each entry names one status and means (value label icon glyph bucket):

  • value – what the database stores, e.g. "todo". Keep it stable: renaming a status means changing its label;
  • label – what a reader is shown, e.g. "To-do";
  • icon – the name of a Phosphor icon, e.g. "anchor";
  • glyph – one character, used in the menus a page cannot dress;
  • bucket – "open", "done" or "rejected". Open statuses are the board's columns and the backlog's live list; the closed ones become their own sections and their own counts.

So an instance whose team works in a speak-now-or-forever flow might say:

(define-public ticket-statuses
  '(("backlog" "Backlog" "tray" "πŸ“₯" "open")
    ("doing" "Doing" "gear-fine" "βš™οΈ" "open")
    ("review" "In Review" "list-magnifying-glass" "πŸ”" "open")
    ("blocked" "Blocked" "prohibit" "🚫" "open")
    ("shipped" "Shipped" "check" "βœ…" "done")
    ("wontfix" "Won't Fix" "x" "❌" "rejected")))

Two things follow from the list being the instance's:

  • the order of the entries is the order of the board's columns and of every status menu;
  • a status that is dropped from the list does not make its tickets disappear. A ticket carrying a value the list does not name is treated as open, so it stays on the board and can be dragged back onto a status that exists.

A status whose label is one of the shipped ones (the list above keeps "To-do", "Work in Progress" and so on) is a translation key and is shown in the reader's own language; a label of your own is shown exactly as written.

A project with its own statuses

A single instance can host very different projects, and one team's board is not always another's. So the instance's ticket-statuses is a default: any project may override the whole list for itself, from its own edit page, in the same one-status-to-a-line format:

todo | Backlog | anchor | βš“ | open
work-in-progress | Doing | gear-fine | βš™οΈ | open
done | Shipped | check | βœ… | done

The fields are value | label | icon | glyph | bucket, in that order, and everything but the value may be left out – the value is then also the label, the icon is a dot and the bucket is open. A line beginning with # is a comment, so the editor is also a place to leave a note. Leaving the field empty gives the project back the instance's list.

Nothing else has to change: the project's backlog, its board, its ticket pages and its status menus are all built from whichever list the project ends up with.

The database

LucidPlan needs a database in the root folder of the project (alongside app, lib, etc.). It uses SQLite by default, and is also compatible with PostgreSQL and MySQL.

If you use SQLite you should enable WAL mode for concurrent writes. The maak task does that for you (and creates the file):

maak init-db

Schema migrations are managed with mutastructura, and they are applied with one task:

maak migrate

maak init-db makes a fresh database and migrates it in one go, so a new instance never has to think about this. On a database that already exists, maak migrate applies whatever it has not seen and leaves the rest alone – running it twice in a row is a no-op, and running it on a current database prints NOTHING TO DO!.

A migration is a plain SQL file under resources/migrations/sqlite/, named YYYY-MM-DD_HH:MM:SS_something.sql: mutastructura applies the files it has not seen in filename order, each in its own transaction, so a migration that fails leaves the schema where it was rather than half-moved. What it has seen is kept in a migrations_history table – the ledger – with the SHA-256 of each file, so editing a migration that has already run is reported as a WARNING: Checksum mismatch instead of being applied a second time. The ledger is the reason migrations are applied by maak migrate and not by hand: a database migrated by hand has no ledger, and its schema can no longer be compared with the migrations.

An existing deployment that was set up before LucidPlan used mutastructura has one of those unledgered databases. Bring it up to date once, by hand, by recording what is already applied – the migration files are not re-run, and nothing in the database changes:

# Run this once, on an instance that was migrated by hand:
sqlite3 lucidplan.db "CREATE TABLE IF NOT EXISTS migrations_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name VARCHAR(255) NOT NULL,
    checksum VARCHAR(64) NOT NULL,
    applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (name, checksum))"
for f in resources/migrations/sqlite/*.sql; do
  sqlite3 lucidplan.db "INSERT OR IGNORE INTO migrations_history (name, checksum) VALUES (
      '$(basename "$f")',
      '$(sha256sum "$f" | cut -d' ' -f1)'))"
done

After that, maak migrate answers NOTHING TO DO! and every later migration is applied by the one task.

Creating the first super-user

A brand-new LucidPlan has no users at all, and the user-management pages need a user to reach them – so the very first account is seeded directly into the database, by hand.

A super-user is simply a user whose permission list contains lucidplan:modify-permissions: that permission lets its holder grant every other permission to themselves and to others, so it is the root of the whole ACL.

  1. Pick a password and a salt (any string works; the salt just has to be kept with the account).
  2. Compute the stored password, which is the SHA-256 of password + salt (password first, salt appended, no separator). You can compute it from the same shell you run the project in:
# With the exact Guile the project uses (inside `guix shell -m manifest.scm`):
guile -c '(use-modules (artanis security nss)) (display (string->sha-256 (string-append "my-password" "my-personal-salt"))) (newline)'
  1. Insert the row:
INSERT INTO "user" (
    id,           -- any unique UUID for this account
    username,
    email,        -- the address you will sign in with
    password,     -- the SHA-256 computed above
    salt,         -- the salt you used above
    permissions   -- a back-quoted Lisp list of symbols
) VALUES (
    '2f6a4d3e-1c2b-4a5f-9e8d-0b7c6a5d4e3f',
    'Joe',
    'you@example.org',
    'the-sha256-you-computed',
    'my-personal-salt',
    '`(lucidplan:modify-permissions lucidplan:ticket-create lucidplan:ticket-delete lucidplan:project-create lucidplan:project-delete lucidplan:user-create lucidplan:user-delete)'
);

The permissions column is read back with Lisp's eval-string, so it must be the text of a back-quoted list – that is, it starts with a backtick (`` ` ``) and contains a Scheme list of the lucidplan:... symbols. The list above is the full set; give the super-user all of them.

sqlite3 lucidplan.db "INSERT INTO \"user\" (id, username, email, password, salt, permissions) VALUES ('2f6a4d3e-1c2b-4a5f-9e8d-0b7c6a5d4e3f','Joe','you@example.org','THE_SHA256','my-personal-salt','\`(lucidplan:modify-permissions lucidplan:ticket-create lucidplan:ticket-delete lucidplan:project-create lucidplan:project-delete lucidplan:user-create lucidplan:user-delete)');"

Running it

If you are hacking away at the project, use the development target: it runs the server with hot-reloading, using the default artanis.conf where hostname, database path, etc. are configured.

maak dev      # hot-reloading development server (alias: maak hr)
maak server   # plain server with the default configuration

By default the server listens on localhost:50025.

For production, use the production configuration (which sets the real host name) and, ideally, run LucidPlan as a Shepherd service so it starts on boot and is restarted if it falls over.

maak production-server

Here follows an example Shepherd service for a Guix System:

(define-module (wolk-jjba lucidplan)
  #:use-module (gnu)
  #:use-module (guix))

(use-service-modules shepherd)

(define-public (wolk-jjba-lucidplan-service config)
  (list (shepherd-service (documentation "Run lucidplan as a daemon")
                          (provision '(lucidplan))
                          (requirement '())
                          (start #~(make-forkexec-constructor '("maak"
                                                                "production-server")
                                    #:directory
                                    "/etc/lucidplan/job-clone/lucidplan/trunk"
                                    #:environment-variables '("GUILE_LOAD_PATH=/run/current-system/profile/share/guile/site/3.0"
                                                              "GUILE_DBD_PATH=/run/current-system/profile/lib"
                                                              "C_INCLUDE_PATH=/run/current-system/profile/include"
                                                              "GUILE_LOAD_COMPILED_PATH=/run/current-system/profile/lib/guile/3.0/site-ccache:/run/current-system/profile/share/guile/site/3.0"
                                                              "LIBRARY_PATH=/run/current-system/profile/lib"
                                                              "GIT_SSL_NO_VERIFY=1"
                                                              "PATH=/run/privileged/bin:/run/current-system/profile/bin:/run/current-system/profile/sbin")))
                          (stop #~(make-kill-destructor))
                          (auto-start? #t)
                          (respawn? #t))))

(define-public wolk-jjba-lucidplan-service-type
  (service-type (name 'lucidplan)
                (description "Run lucidplan as a daemon")
                (extensions (list (service-extension
                                   shepherd-root-service-type
                                   wolk-jjba-lucidplan-service)))
                (default-value '())))


Creating the other users

Once you can sign in as the super-user, the rest of the team is created from the interface, not from SQL:

  1. Sign in at /login with the super-user you seeded.
  2. Open user management (the /users/manage page, linked from your profile page at /me).
  3. Press new user, give the account a username and an email, tick the permissions it should hold, and create it.

A new account is created with no password. Instead, LucidPlan sends its owner an email containing a one-time link (a password reset) with which they set their own password. An account is therefore created by the operator and claimed by its owner – the operator never chooses somebody else's password.

For that email to arrive, the mail service (pingwing-url and its credentials, above) has to be configured and reachable. If a message could not be delivered, the account still exists and the invite can be sent again: on the user-management page every account has a resend invite button, and the page tells you plainly when a message failed to go out. Messages are also retried a few times before giving up.

To give a single person the ability to manage users, tick lucidplan:modify-permissions for them. To let someone create and edit tickets but nothing else, tick only lucidplan:ticket-create.

Host name, TLS and search engines

For a public instance, point host.name (in conf/production.conf) at your domain and put LucidPlan behind a reverse proxy that terminates TLS, since the session cookie is set secure and will not be stored over plain HTTP. Set site-url in env.scm to the public address so the canonical links and preview images are absolute.

Search-engine basics are handled for you: every page carries a title, a description, a canonical address and Open Graph/Twitter preview tags, plus a JSON-LD block describing the application. /robots.txt is generated and points at a /sitemap.xml that lists the public pages (the front page, the about page, the project list, and each project's backlog and board).

The theme, and the files it is made of

LucidPlan wears a theme it serves itself. The Lucida faces, the two web font families the stylesheet names after them, the Phosphor icon face, the stylesheets that describe them, and the image a link preview shows are all files of this project, under pub/css/, pub/fonts/ and pub/img/. A page load therefore asks this instance for them and no other host: nothing about your visitors is sent to a CDN or a font service, and nothing about your instance is told to anybody you did not choose.

The files are committed, so a fresh checkout serves them without any extra step. They are refreshed from where they came from by one task, which names each source:

maak fetch-assets

Where the instance hands them out is configuration. Left alone, a page names them by path – /css/fonts.css – and the instance that served the page answers for them, which is what a self-hosted deployment wants. Set asset-url in env.scm to an absolute base to hand that work to a static host or a CDN instead:

(define-public asset-url "https://assets.example.org/lucidplan")

Page analytics is also yours to decide. The instance's own page-view script reports to analytics-endpoint, and setting it empty means the script is not put on the page at all – no request leaves the reader's browser:

(define-public analytics-endpoint "")

Tests

Tests are run with veritas:

maak test

They live under test/veritas/: unit/ for the tests that run on every change, and benchmark/ for those that measure rather than assert. A test is a module like any other and is loaded with the application's own directories on the path, so it can use-modules what it tests. Every push to the main branch runs the suite on byggsteg before anything is deployed.

Translations

The interface is translated with gettext. The catalogs live under sys/i18n/locale/. The languages offered today are English (US), Nederlands, EspaΓ±ol and ζ—₯本θͺž. When you add translatable strings (they are written as (G_ "...")), refresh and compile the catalogs:

maak i18n-update-template   # collect new strings into the .pot template
maak i18n-merge             # merge the template into every .po
maak i18n-compile           # compile the .po files into the .mo the server reads

The languages offered in the interface are configuration: set locales in env.scm to the catalogs you actually have.


Authentication, Authorization, User management

bewaking πŸ”— is a nice library to help you secure your web applications with Guile Scheme.

With bewaking we use secure tokens encrypted and stored in secure cookies (for browsers) and headers (for REST API) as well as authentication and authorization, with ACLs (access control list) and fine-grained permissions.

If you want to be a super user/root user in lucidplan your token's permissions list will need to contain the lucidplan:modify-permissions permission, effectively meaning you can give yourself and others all wanted permissions and do everything.

LucidPlan's permission model ACL:

  • lucidplan:ticket-create
  • lucidplan:ticket-delete
  • lucidplan:project-create
  • lucidplan:project-delete
  • lucidplan:user-create
  • lucidplan:user-delete
  • lucidplan:modify-permissions

LucidPlan requires an authenticated session for performing administrative tasks such as creating/deleting tickets and projects. For web browsers you can simply use the login page; for scripts and headless usage, use the /api/v1/login endpoint and take the x-bewaking-lucidplan response header.

The REST API

The versioned API lives under /api/v1 and speaks JSON. All of it is guarded by the same ACL as the pages, so a token carries exactly the permissions its owner has.

Method and path Answer Notes
POST /api/v1/login 204 and the token, or 401 Body: email and password, URL-encoded
GET /api/v1/projects the project list Token header needed
GET /api/v1/tickets/:project a project's tickets :project is the project's human id
# Sign in and keep the token the service answers with:
TOKEN=$(curl -s -D - -o /dev/null -X POST \
  -d 'email=you@example.org&password=your-password' \
  http://localhost:50025/api/v1/login | awk '/[Xx]-bewaking-lucidplan/{print $2}' | tr -d '\r')

# Ask for a project's tickets with it:
curl -s -H "x-bewaking-lucidplan: $TOKEN" \
  http://localhost:50025/api/v1/tickets/lucidplan

Useful query

-- INSERT INTO "user" (
--     username,
--     email,
--     password,
--     salt,
--     permissions
-- ) VALUES (
--     'Joe',
--     'jjbigorra@gmail.com',
--
-- you should compute a
-- SHA-256 hash of (password + salt)
--
--     'e3b0c44298fc1c149afbf4c.....5',
--     'my-personal-salt',
--
-- here we pass the list of permissions
-- as a backquoted Lisp list of symbols
--
--     '`(lucidplan:modify-permissions lucidplan:ticket-create lucidplan:ticket-delete lucidplan:project-create lucidplan:project-delete lucidplan:user-create lucidplan:user-delete)'
-- );

On accessibility

We believe creating accessible websites for everyone, including people with disabilities, is crucial for equality and helps provide a good user experience.

LucidPlan chooses lots of its UI to appeal to the widest public possible by default, but is very configurable to allow extensibility.


Contributing

Contributing to free software (as in freedom) is a uniquely beautiful act because it embodies principles of generosity, collaboration, and empowerment. We welcome everyone to feel invited to the LucidPlan Project, and we encourage active contribution in all forms: to improve it and/or suggest improvements, report issues, brainstorm with me, make it more modular/flexible, etc.

Reporting a bug or an idea

Open an issue on the repository's homepage, also make sure to check the project's own backlog and kanban board as they are public.

A report is easiest to act on when it says what you did, what you expected and what happened instead – plus your Guile, Artanis and (if you use it) Guix versions, and whether you are on a released tag or trunk.

You can also mail me <jjbigorra@gmail.com> to chat, discuss or send feedback.

Sending a PR/patch

The branch is trunk. Small, focused commits are the easiest to review; run maak fmt to format Scheme according to the Guix style guide, and maak test before you send anything. A patch that touches a page usually wants its strings wrapped in (G_ "...") so they can be translated (see Translations).

Trying it before you trust it

Everything here is meant to be read and run: clone it, run maak dev, change a string in a view, and watch the page change under you. If something in this manual does not match what the code does, that is a bug in the manual – please tell us.


Licensing

lucidplan and all of its source code are free software, licensed under the GNU Affero General Public License v3 (or newer at your convenience).

https://www.gnu.org/licenses/agpl-3.0.html

The documentation and examples, including this document, which are provided with lucidplan, are all licensed under the GNU Free Documentation License v1.3 (or newer at your convenience).

https://www.gnu.org/licenses/fdl-1.3.html


AI Policy

This project adheres to the jointhefreeworld AI (Artificial Intelligence) policy.

Our core principle is simple: AI should assist human creativity and problem-solving, never replace human reasoning.

While tools like Large Language Models (LLMs) and interactive chatbots can be beneficial for reviewing, refactoring small functions, or acting as a sounding board, they should be used with moderation.

We require a human in the loop for all contributions. The use of autonomous AI agents to automatically generate and submit pull requests to this project is strictly prohibited.


Code of conduct

This project adheres to the jointhefreeworld code of conduct. Find it here:

https://jointhefreeworld.org/blog/articles/personal/jointhefreeworld-code-of-conduct/index.html

In summary, we foster an inclusive, respectful, and cooperative environment for all contributors and users of this free software project. Inspired by the ideals of the GNU Project, we strive to uphold freedom, equality, and community as guiding principles. We believe that collaboration in a community of mutual respect is essential to creating excellent free software.


About this project

See the Backlog and Kanban for this project.

On Lucida fonts

Lucida fonts are proprietary typefaces originally developed by Bigelow & Holmes. They remain a long-time favorite for many people and are very legible in many situations and hardware/software. While they are bundled pre-installed on many operating systems and platforms, they remain proprietary fonts.

You may use these font files under principles of fair use or for local development and non-commercial purposes, provided it is not for profit and used without securing a proper commercial license where required by law. Users and contributors are solely responsible for ensuring their usage complies with applicable software licensing terms and intellectual property rights.

The faces the interface is set in live in pub/fonts/ as files of this project, and are served by the instance itself (see The theme, and the files it is made of). An operator who is not licensed to distribute them may drop them from pub/fonts/ and either set asset-url to a host that serves them, or remove the @font-face rules that name them from pub/css/fonts.css: the stylesheet's families then fall through to whatever the reader's own system offers, and nothing else changes.

On Reproducibility: Why GNU Guix?

A build should never fail because "it worked on my machine." Reliable software development requires explicit, bit-for-bit reproducibility and a functional approach to package management and system deployment.

GNU Guix logo
  • Declarative Build Graphs: Packages are pure functions of their inputs with no implicit system state.
  • Programmable with Guile: Full system and package configurations written in Guile Scheme rather than rigid DSLs or static files.
  • Time Travel: Rebuild the exact system state from any point in git history using guix time-machine.
  • Atomic Operations: Instantly upgrade or roll back environments without leaving orphaned dependencies or broken states.
  • Unified Tooling: One tool manages development shells, isolated containers, package builds, and full system configurations.

On GNU Artanis

Find the documentation for Artanis here: https://www.gnu.org/software/artanis/manual/

GNU Artanis is the first production-level modern Web framework of the Scheme programming language.

It is designed and maintained to be robust, fast, and easy to use for professional web development.

Here lies not just a framework, but a canvas where your ideas take flight with remarkable speed and grace. Artanis isn't about wrestling with complexity; it's about the joy of creation.

It's about harnessing the power of a robust and mature language to build something truly your own, something beautiful, nimble, flexible, powerful, efficient, understandable and maintainable. GNU Artanis was Certificated as Awesome Project at 2013 Lisp in summer projects.

One day, the folks at GNU were discussing what language they would write the GNU website in - and many chose Python. But I found that strange, because the official extension language of GNU is GNU Guile. And I wondered aloud - why not start a brand new project to provide a web framework written with GNU Guile? To which RMS said, "It's cool, I like this idea."

But at that time, it was just an idea without a plan.

Fortunately, a few months later, the Guile community held a hack-potluck to celebrate Guile2 turning two - which is a contest to write a cool program in a few weeks. And so, Artanis was born.

History:

February 2013 - Artanis born at the GNU Guile hack-potluck. 2013 - Artanis submitted to "Lisp In Summer Projects" contest. Received "Certificated awesome project" award in 2014. 1st January, 2015 - the first stable version Artanis-0.0.1 was released. 19th January, 2015 - offers Artanis to FSF/GNU, and RMS inducts it as an official GNU project. Artanis becomes GNU Artanis. 29 December 2021 - GNU Artanis donates to HardenedLinux community to connect with product environment more tightly.

Thanks

LucidPlan stands on a lot of other free software – hygguile, kracht, scriba, bewaking, orgwebalchemy, mutastructura, veritas, maak, Olive CSS, pingwing and byggsteg – most of them part of jointhefreeworld, all of them worth a look.

Author: Josep Bigorra

Created: 2026-10-22 Thu 20:43

Validate