Make a Theme
A theme changes the look of every screen. A theme is a folder of text files. You need no compiler and no code. In this tutorial you build a small dark theme named Harbor, from theme init to theme pack.
- Data only: TOML and JSON files. A theme has no scripts, no network access and no file access
- Eight screens are required on the desktop. You write them as small JSON trees of components
- One validator prints the file, the position and the rule of each error
- Details are in the Theme Reference
Step 1: Make the Starter Folder
Run this command in an empty working folder:
aivoryd theme init harbor
The command prints made harbor; edit it, then run: aivoryd theme dev harbor. The folder has these files:
The starter is valid. Each screen is a panel that shows the daemon version. Start the checker and keep it open in a second terminal:
aivoryd theme dev harbor
It prints ok: harbor 0.1.0 after each save when the theme is valid. Otherwise it prints one line for each error.
--from <id> copies an installed theme instead of the starter. The eight built-in looks are compiled into the app. They are not installed themes, so --from holo fails with theme "holo" is not installed.
Step 2: Write the Manifest
Open harbor/theme.toml. Set a name, a description and your author data:
id = "harbor"
name = "Harbor"
description = "A calm dark theme with one decision at a time."
version = "0.1.0"
license = "MIT"
min_app = "2026.1.0"
catalog = 1
displays = ["desktop"]
tokens = ["tokens.toml"]
[author]
name = "Jane Doe"
url = "https://github.com/janedoe"
displays lists the display classes of your layouts. desktop is required. Every key is explained in the manifest reference.
Step 3: Set the Tokens
Tokens are the named values of a theme. Layouts use the names, never raw values. Replace harbor/tokens.toml:
[colors]
bg = "#0d1117"
surface = "#161b22"
text = "#e6edf3"
muted = "#8b949e"
accent = "#58a6ff"
analysis = "#a371f7"
[colors.state]
needsYou = "#f0883e"
working = "#58a6ff"
idle = "#6e7681"
parked = "#6e7681"
error = "#f85149"
done = "#3fb950"
[fonts]
ui = "sans"
mono = "mono"
[type]
xs = 11
sm = 13
md = 15
lg = 20
xl = 28
[space]
edge = 24
gap = 12
[radii]
card = 8
[motion]
easing = "easeOut"
[motion.durations]
fast = 120
[effects]
glass_alpha = 0.6
The colors, the fonts and the five sizes in type are required. space and radii are open tables: you choose the names. A layout writes "pad": "edge" to read space.edge. Use color for state only: colors.state.* has one color for each of the six session states.
Step 4: Add Texts and a Shared Part
Put texts in strings/en.toml. A layout reads a text as $strings.key:
title = "Fleet"
needs = "Needs you"
working = "Working"
empty = "Nothing here yet."
decisions = "Waits for you"
repos = "Repos"
tasks = "Queued tasks"
A part is a layout snippet that many screens share. Make harbor/parts/topbar.json. It has the screen navigation, two counters and the look switch:
{
"root": { "type": "stack", "dir": "row", "gap": "gap", "align": "center", "children": [
{ "type": "nav", "variant": "pill" },
{ "type": "spacer" },
{ "type": "readout", "dir": "row", "label": "$strings.needs", "value": "$gates.needsYou.count", "color": "needsYou" },
{ "type": "readout", "dir": "row", "label": "$strings.working", "value": "$gates.working.count", "color": "working" },
{ "type": "look_switch" }
] }
}
$gates.needsYou.count is a data path. The app computes it. A layout never calculates. The nav and look_switch components list the screens and the looks for you. All data paths are in the reference.
Step 5: Write the War Room
Replace harbor/layouts/desktop/warroom.json. The sessions component holds one template for each count of live sessions. The tier 13-30 is required and must be a list:
{
"root": { "type": "stack", "id": "warroom", "pad": "edge", "gap": "gap", "children": [
{ "use": "topbar" },
{ "type": "stack", "dir": "row", "gap": "gap", "grow": 1, "children": [
{ "type": "sessions", "grow": 1, "gap": "gap", "tiers": {
"1": { "layout": "grid", "template":
{ "type": "card", "title": "$item.title", "sub": "$item.codebase",
"on": { "press": { "action": "focus", "id": "$item.id" } },
"children": [
{ "type": "badge", "state": "$item.state" },
{ "type": "text", "value": "$item.prompt", "color": "muted", "lines": 3 }
] } },
"5-12": { "layout": "grid", "template":
{ "type": "card", "title": "$item.title", "sub": "$item.codebase",
"on": { "press": { "action": "focus", "id": "$item.id" } },
"children": [ { "type": "badge", "state": "$item.state" } ] } },
"13-30": { "layout": "list", "template":
{ "type": "row", "title": "$item.title", "sub": "$item.part", "meta": "$item.state",
"state": "$item.state",
"on": { "press": { "action": "focus", "id": "$item.id" } } } }
} },
{ "type": "panel", "variant": "flat", "title": "$strings.decisions", "width": 360, "children": [
{ "type": "decision_queue", "limit": 3 },
{ "type": "text", "value": "$strings.empty", "color": "muted",
"when": { "path": "$asks", "is": "empty" } }
] }
] },
{ "type": "composer", "placeholder": "Start a task" }
] }
}
Read it from the inside out:
| Part | What it does |
|---|---|
$item |
The current session inside a tier template. |
"on": { "press": { "action": "focus", ... } } |
A click opens the Focus screen of that session. |
Tiers 1 and 5-12 |
Cards in a grid. The tier 2-4 has no template, so it uses tier 1. |
Tier 13-30 |
Rows grouped by codebase. Tier 31+ uses it too. |
decision_queue |
The app draws the question, the reason and the option buttons. Your theme picks only the place. |
when |
The “Nothing here yet.” text shows only when $asks is empty. |
composer |
A text box with the Send button of the app. Its text is the only way to send text to an agent. |
Save. The checker must still print ok.
composer that the user types into.
Step 6: Write the Other Screens
All eight desktop screens are required. A missing screen is an error. Write the next three files. In each one, $selected is the session on view.
layouts/desktop/session.json and layouts/desktop/focus.json have the same content with another id:
{
"root": { "type": "stack", "id": "session", "pad": "edge", "gap": "gap", "children": [
{ "use": "topbar" },
{ "type": "panel", "variant": "flat", "title": "$selected.title", "grow": 1, "children": [
{ "type": "transcript", "grow": 1 }
] },
{ "type": "composer" }
] }
}
layouts/desktop/decision.json is a dialog that opens over another screen. The app adds the Close button:
{
"root": { "type": "stack", "id": "decision", "pad": "edge", "gap": "gap", "children": [
{ "type": "text", "value": "$selected.title", "size": "lg", "weight": "bold" },
{ "type": "decision", "free_text": true }
] }
}
layouts/desktop/inbox.json:
{
"root": { "type": "stack", "id": "inbox", "pad": "edge", "gap": "gap", "children": [
{ "use": "topbar" },
{ "type": "scroll", "grow": 1, "children": [
{ "type": "decision_queue", "free_text": true },
{ "type": "text", "value": "$strings.empty", "color": "muted",
"when": { "path": "$asks", "is": "empty" } }
] }
] }
}
layouts/desktop/tasks.json:
{
"root": { "type": "stack", "id": "tasks", "pad": "edge", "gap": "gap", "children": [
{ "use": "topbar" },
{ "type": "readout", "label": "$strings.tasks", "value": "$tasks.queued" },
{ "type": "composer", "mode": "dispatch", "placeholder": "Queue a task" }
] }
}
layouts/desktop/repos.json uses list to repeat a row for each repo:
{
"root": { "type": "stack", "id": "repos", "pad": "edge", "gap": "gap", "children": [
{ "use": "topbar" },
{ "type": "panel", "variant": "flat", "title": "$strings.repos", "grow": 1, "children": [
{ "type": "list", "items": "$repos.items", "empty": "$strings.empty", "children": [
{ "type": "row", "title": "$item.name", "sub": "$item.branch", "meta": "$item.sessions" }
] }
] }
] }
}
layouts/desktop/settings.json must hold a settings_section. The app draws the Settings screen there, so Settings > Display always stays reachable:
{
"root": { "type": "stack", "id": "settings", "pad": "edge", "gap": "gap", "children": [
{ "use": "topbar" },
{ "type": "settings_section", "grow": 1 }
] }
}
When all eight files are saved, the checker prints ok: harbor 0.1.0.
Step 7: Read the Errors
Break something on purpose and read the message. Change "type": "panel" to "type": "panl" in tasks.json:
The line has the file, the JSON pointer and the rule. The common messages are in the reference. Undo the change.
Step 8: Install and Try the Theme
- Install the folder. A local folder installs straight into the themes folder of the daemon:
aivoryd theme add ./harbor
- Start the desktop app with demo data:
aivory --demo --demo-rich
- Open Settings > Display. In the block Themes, press Use next to Harbor.
To test the tiers, set the number of live demo sessions:
AIVORY_DEMO_SESSIONS=30 aivory --demo --demo-rich
On Windows PowerShell, set $env:AIVORY_DEMO_SESSIONS = 30 first.
The app checks your theme each time it loads it. If the theme has an error, the app uses Minimal and shows the message.
theme add refuses an id that is installed already. To try a new edit, remove the theme and add it again. The remove needs a running daemon, for example the one that the app starts:
aivoryd theme remove harbor
aivoryd theme add ./harbor
You can also edit the installed copy in the themes folder of the daemon data folder: %LOCALAPPDATA%\aivory\code\themes on Windows, ~/Library/Caches/aivory-code/themes on macOS, ~/.cache/aivory-code/themes on Linux. AIVORY_CACHE_DIR sets another data folder.
Step 9: Check and Pack
Check the folder once, with the tag that you plan to release:
aivoryd theme check ./harbor --tag v0.1.0
Then make the archive:
aivoryd theme pack ./harbor
The command writes harbor-0.1.0.aivory-theme.zip in the current folder and prints its SHA-256. It refuses to pack a theme that has errors. To share the theme, see Publish and Install Themes.
Where to Go From Here
| You want to | Read |
|---|---|
| Know every field and limit | Theme Reference |
| Use a font or an image | Put it in assets/fonts/ or assets/images/ with a LICENSE file. See assets and limits |
| Add a phone or watch layout | Add phone or watch to displays and a folder under layouts/. No client draws them yet |
| Use a scene or an effect | Add the node with a fallback. The desktop app draws the fallback only |