Code

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:

h a r t t s l L R b h o t a I E o e k r y C A r m e i o E D / e n n u N M . s g t S E t . s s E . o t / m m e d d l m n e l . s t k o t m o l p / w a r r o o m , s e s s i o n , i n b o x , t a s k s , r e p o s , s e t t i n g s , f o c u s , d e c i s i o n

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.

Note --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.

Warning A theme cannot put a fixed prompt on a button or answer a decision itself. Text for an agent comes only from a 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:

l 1 a y e o r u r t o s r / ( d s e ) s k t o p / t a s k s . j s o n / r o o t / t y p e : u n k n o w n c o m p o n e n t " p a n l "

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

  1. Install the folder. A local folder installs straight into the themes folder of the daemon:
aivoryd theme add ./harbor
  1. Start the desktop app with demo data:
aivory --demo --demo-rich
  1. 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.

Note Test with 1, 6 and 30 sessions. Test a narrow window (under 960 px) and a wide one (1440 px and more).

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

Next Steps