Create a Custom Obsidian Theme
Purpose
Return the complete, ordered, authoritative procedure for building a custom Obsidian app theme, sourced from the Obsidian Developer Documentation (docs.obsidian.md): the required files, the manifest.json schema, how styling is done through built-in CSS variables (light/dark/:root), how to embed assets offline, how to automate GitHub releases, and how to submit to the community directory. Read-only — this skill only reads documentation; it never edits a vault, submits a theme, or drives the Obsidian app itself.
docs.obsidian.md is an Obsidian Publish site (a JS-rendered SPA). Its page content is served as raw markdown from a public, unauthenticated Publish access API, so the fastest and most reliable path is to fetch the markdown directly rather than render the SPA in a browser.
When to Use
- A user asks "how do I create / build / make a theme for Obsidian?" and wants a step-by-step answer.
- An agent needs the canonical
manifest.jsonfields for a theme, or the exact CSS-variable override pattern (bodyvs.theme-light/.theme-darkvs:root). - Bootstrapping or scaffolding an Obsidian theme repo, wiring up the GitHub Actions release workflow, or preparing a community-directory submission.
- Any time you'd otherwise scrape the rendered docs pages — the markdown API is faster, cheaper, and structurally cleaner.
Workflow
The site is a thin Obsidian Publish SPA. Every doc page is fetchable as raw markdown from the Publish access API — no auth, no cookies, no anti-bot, no residential proxy required (verified with a bare browse cloud fetch, HTTP 200). Lead with the fetch path.
Site UID (stable): caa27d6312fe5c26ebc657cc609543be
Raw-markdown endpoint: https://publish-01.obsidian.md/access/<UID>/<PATH>.md
1. (Optional) Discover the page tree
The full navigation tree + ordering lives in the site options blob:
GET https://publish-01.obsidian.md/options/caa27d6312fe5c26ebc657cc609543be
Its navigationOrdering[] array lists explicitly-ordered pages; unordered pages fall back to alphabetical. The app-theme pages live under Themes/App themes/ (distinct from Themes/Obsidian Publish themes/, which is a different topic).
2. Fetch the theme docs as markdown
Spaces in the path are URL-encoded as %20 (the access API rejects raw spaces with 400 url must match format "uri"). Fetch these pages:
| Page | Path (append .md, URL-encode spaces) |
|---|---|
| Build a theme (main tutorial) | Themes/App themes/Build a theme |
| Theme guidelines | Themes/App themes/Theme guidelines |
| Embed fonts and images in your theme | Themes/App themes/Embed fonts and images in your theme |
| Release your theme with GitHub Actions | Themes/App themes/Release your theme with GitHub Actions |
| Submit your theme | Themes/App themes/Submit your theme |
| Manifest schema | Reference/Manifest |
| About styling | Reference/CSS variables/About styling |
| CSS variables (400+ vars) | Reference/CSS variables/CSS variables |
UID=caa27d6312fe5c26ebc657cc609543be
browse cloud fetch "https://publish-01.obsidian.md/access/$UID/Themes/App%20themes/Build%20a%20theme.md"
# The JSON envelope's `content` field is the raw markdown. A non-existent
# page returns HTTP 200 with body "## Not Found\n\nFile X.md does not exist."
3. Synthesize the theme-creation procedure
The authoritative flow the docs describe:
- Download the sample theme into your vault's themes folder:
cd <vault>/.obsidian/themesthengit clone https://github.com/obsidianmd/obsidian-sample-theme.git "Sample Theme". (The repo is a GitHub template, so you can also "Use this template" to create your own.) - Enable it in Obsidian: Settings → Appearance → Themes → Sample Theme.
- Edit
manifest.json— setnameto your theme's human-friendly display name, then rename the theme directory underthemes/to exactly matchname. Restart Obsidian after anymanifest.jsonchange. - Style via CSS variables in
theme.css— Obsidian exposes 400+ CSS variables. Override:- theme-agnostic values (fonts, sizes) under
body { --font-text-theme: Georgia, serif; } - color-scheme colors under
.theme-dark { --background-primary: #18004F; }and.theme-light { --background-primary: #ECE4FF; } - variables that must reach every child element (often plugin/input vars) under
:root { --input-hover-border-color: red; }— use sparingly.theme.csschanges hot-reload without restarting Obsidian (unlikemanifest.json).
- theme-agnostic values (fonts, sizes) under
- Discover which variable styles an element via DevTools (
Ctrl/Cmd+Shift+I): Sources → top → obsidian.md → app.css (scroll to top for the full variable list; search" --prefix"with two leading spaces to find definitions), or use the element picker and read the Styles panel (e.g.background-color: var(--ribbon-background)). - Keep assets local — community themes may not load remote content. Embed fonts/images as base64 data URLs:
url("data:<MIME>;base64,<DATA>"). - Automate releases with GitHub Actions — add
.github/workflows/release.ymltriggered on tag push, runninggh release create "$tag" --generate-notes --draft manifest.json theme.css. Thengit tag -a x.y.z -m "x.y.z" && git push origin x.y.z, and publish the resulting draft release. - Submit to the community directory — ensure the repo root has
README.md,LICENSE, a screenshot (~512×288 px), andmanifest.json; create a GitHub release whose tag matchesmanifest.json'sversion; then at community.obsidian.md sign in, link GitHub, Themes → New theme, enter the repo URL, agree to the Developer policies, and Submit.
4. manifest.json fields (theme)
From Reference/Manifest. A theme manifest uses only the shared properties (the description/id/isDesktopOnly fields are plugin-only and do not apply to themes):
| Field | Required | Notes |
|---|---|---|
name | yes | Display name; must exactly match the theme's directory name. Cannot be changed after submission. Basic-Latin only, no emoji/special chars (hyphen/+/parens allowed). |
version | yes | Semantic Versioning, strictly x.y.z. The release tag must equal this. |
author | yes | Author name. |
minAppVersion | yes | Minimum supported Obsidian version. |
authorUrl | no | Author website. |
fundingUrl | no | String or object of funding links. |
Browser fallback
If the markdown API is ever unreachable, drive the rendered SPA:
browse open "https://docs.obsidian.md/Themes/App+themes/Build+a+theme" --remote
browse wait load --remote
browse wait timeout 3000 --remote # SPA hydrates ~2-3s AFTER load fires
browse get markdown body --remote # prefer markdown/text AFTER the wait
Note the rendered-URL form uses + for spaces (e.g. /Themes/App+themes/Build+a+theme), unlike the %20 of the raw access API. A bare (non-proxied) session renders these pages fine; the autobrowse validation run used --proxies and also succeeded.
Site-Specific Gotchas
- It's an Obsidian Publish SPA, not static HTML. A plain fetch of
https://docs.obsidian.md/<page>returns a ~2.7 KB JS shell, not the article. The real content is thecontentfield ofhttps://publish-01.obsidian.md/access/<UID>/<PATH>.md. - Two different URL encodings. The raw access API needs
%20for spaces and returns400 url must match format "uri"on raw spaces. The rendereddocs.obsidian.mdURLs use+for spaces. Don't mix them. - 404s masquerade as 200. A missing markdown page returns HTTP 200 with body
## Not Found\n\nFile <path>.md does not exist.— check the body, not just the status. This is how the wrong path guesses (Themes/Build a theme.md) were ruled out; the real tree isThemes/App themes/.... navigationOrderingis partial. The options blob only lists explicitly-ordered pages (e.g. it omits severalThemes/App themes/pages that clearly exist). Don't treat it as the complete file list — probe expected paths directly or read the rendered sidebar.browse get text bodytoo early returns the preload shell. During the browser run, snapshotting before hydration yielded only the inline(function(){let t=localStorage.getItem('site-theme')...})bootstrap script instead of article text. Alwayswait load+wait timeout ~3000before extracting on the browser path.- No auth / no proxy needed for the markdown API. Cloudflare fronts the site (the pre-run probe flagged
likelyNeedsProxies: truefor the homepage), but the Publish access + options endpoints returned 200 on a barebrowse cloud fetch.verified/proxiesare not required for the recommended fetch path. manifest.jsonchanges require an Obsidian restart;theme.csschanges hot-reload. Renaming the theme requires the directory name to exactly equalmanifest.json'sname.- App themes ≠ Publish themes.
Themes/App themes/(this skill) covers the desktop/mobile app;Themes/Obsidian Publish themes/is a separate topic for styling published sites — don't conflate them. - Submission has two coupled requirements. The community directory reads
manifest.jsonat the default branch HEAD, but installs pullmanifest.json+theme.cssfrom the GitHub release whose tag matches the manifestversion. Both the committed manifest and a matching-tag release must exist.
Expected Output
{
"success": true,
"source": "docs.obsidian.md (Obsidian Developer Documentation)",
"sample_repo": "https://github.com/obsidianmd/obsidian-sample-theme",
"required_files": ["manifest.json", "theme.css"],
"manifest_required_fields": ["name", "version", "author", "minAppVersion"],
"manifest_optional_fields": ["authorUrl", "fundingUrl"],
"styling_method": "Override built-in CSS variables in theme.css: `body` for theme-agnostic values (fonts/sizes), `.theme-light`/`.theme-dark` for color-scheme colors, `:root` sparingly for variables that must reach every child element.",
"steps": [
"Clone obsidian-sample-theme into <vault>/.obsidian/themes",
"Enable it via Settings > Appearance > Themes",
"Set manifest.json `name`, rename the theme dir to match, restart Obsidian",
"Override CSS variables in theme.css (body / .theme-light / .theme-dark / :root)",
"Find element variables via DevTools Sources > app.css or the element picker",
"Embed fonts/images as base64 data URLs (no remote assets)",
"Add .github/workflows/release.yml and tag a release with GitHub Actions",
"Submit at community.obsidian.md (README, LICENSE, screenshot, manifest, matching release)"
],
"css_variables_reference": "https://docs.obsidian.md/Reference/CSS+variables/CSS+variables",
"release_workflow": {
"method": "GitHub Actions (.github/workflows/release.yml) triggered on tag push",
"assets_uploaded": ["manifest.json", "theme.css"],
"version_format": "Semantic Versioning x.y.z; release tag must equal manifest.json version"
},
"submit_url": "https://community.obsidian.md",
"theme_guidelines": [
"Override CSS variables instead of targeting specific classes",
"Use low-specificity selectors so Obsidian updates don't break the theme",
"Keep all assets local (base64 data URLs); no remote network calls",
"Avoid !important so users can still override with snippets"
],
"error_reasoning": null
}
Failure shape (e.g. docs restructured / page not found):
{
"success": false,
"error_reasoning": "Fetched Themes/App themes/Build a theme.md returned '## Not Found' — the docs tree may have been reorganized; re-derive paths from the /options/<UID> navigationOrdering blob."
}