# Building Solid Apps

> How to build an app for the solid-apps org — file anatomy, auth, established data models, the intent bus, UI conventions, distribution, and the gotchas. Use when creating or modifying a solid-apps app, or wiring apps together.

- Skill: `solid-apps/building-solid-apps` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add solid-apps/building-solid-apps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/solid-apps/building-solid-apps/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: solid-apps (https://skillmd.com/u/solid-apps)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/solid-apps/building-solid-apps

---


# Building a solid-app

A **solid-app** is a tiny, build-free web app served from a Solid pod at
`/public/apps/<name>/`. No framework, no bundler, no npm at runtime — plain
HTML + one `app.js` + `style.css`. Its data lives on the pod as **JSON-LD**,
read/written over HTTP with the owner's session. Apps are independent but
**interoperate by sharing data models and an intent bus** — that's what makes
the collection feel like one OS rather than a pile of bookmarks.

Each app is its own GitHub repo under `solid-apps`, **default branch `gh-pages`**
(served raw + via the store).

## 1. Anatomy

```
index.html      topbar + #app + <script> tags
app.js          the app (vanilla JS)
style.css       slate topbar (#2a3140) + your accent
manifest.json   PWA manifest + "handles": [intent types]
favicon.svg     slate rounded tile + white glyph (see §6)
README.md       what it is + the data model
LICENSE         AGPL-3.0-only
```

`index.html` skeleton — copy this:

```html
<!doctype html>
<html lang="en"><head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>NAME — your Solid pod</title>
  <link rel="icon" href="favicon.svg" type="image/svg+xml">
  <link rel="manifest" href="manifest.json">
  <link rel="stylesheet" href="style.css">
</head><body>
  <div class="topbar">
    <span class="brand">NAME</span>
    <a class="root" href="../../../" title="Go to your pod root">↑ root</a>
  </div>
  <div class="wrap"><div id="app"></div></div>
  <script src="https://unpkg.com/xlogin"></script>
  <!-- <script src="intents.js"></script>  if it shares/receives -->
  <script src="app.js"></script>
</body></html>
```

## 2. Auth (xlogin)

Sign-in is the universal Solid/Nostr login pill. Standard helpers:

```js
const authFetch = (url, opts) => ((window.xlogin && window.xlogin.authFetch) || fetch)(url, opts)
const loggedIn  = () => !!(window.xlogin && window.xlogin.id)
const myWebId   = () => (window.xlogin && window.xlogin.id) || null
// re-render when sign-in state changes:
document.addEventListener('xlogin', render)
document.addEventListener('xlogout', render)
```

Reads of `/public/...` work signed-out; writes (and any `/private/...` read)
need `authFetch` + sign-in. Show a sign-in note instead of failing silently.

## 3. Pod paths

The app sits at `/public/apps/<name>/`, so resolve everything relative:

```js
const ROOT     = '../../../'                              // pod root
const CONTACTS = new URL('../../contacts/', location.href)        // /public/contacts/
const PRIVATE  = new URL('../../../private/foo/', location.href)  // /private/foo/
```

## 4. Data: use the established model, don't invent

Store JSON-LD on the pod with the **same ontology + location** an existing app
uses, so apps interoperate (don't make a parallel silo — that mistake got the
losos `schema:ItemList` todo model rejected in favour of `wf:Tracker`).

| Data | @type / ontology | location |
|---|---|---|
| tasks/trackers | `wf:Tracker` + `ical:Vtodo` (`summary`,`status`,`issue:[]`) | `/public/tracker/<slug>-data.jsonld` |
| calendar | `ical:Vcalendar` + `ical:Vevent` (`summary`,`dtstart`,`component:[]`) | `/public/calendar/calendar.jsonld` |
| notes | `schema:TextDocument` (`headline`,`text`,`datePublished`) | `/public/note/` |
| bookmarks | `bookmark:Bookmark` (`dc:title`,`bookmark:recalls`,`dcterms:created`) | `/public/bookmark/` |
| contacts | `vcard:Individual` (`vcard:fn`,`hasEmail`,`hasTelephone`,`url`=WebID) | `/public/contacts/` |
| messages | ActivityStreams `Create{Note}` | `/private/chats/<base64url(webid)>/` |

`/public/` = shareable (public-read on JSS); `/private/` = owner-only. Sensitive
data (chats, places) → `/private/`. Discover items by listing a container
(`ldp:contains`) or via the type index (what pilot/hub use).

**Read/write helpers:**
```js
function ldpContains(doc){ const c=doc['ldp:contains']||doc['http://www.w3.org/ns/ldp#contains']||doc.contains||[]; return (Array.isArray(c)?c:[c]).map(x=>typeof x==='string'?x:x['@id']||x.id).filter(Boolean) }
// PUT with a one-shot container-create fallback (JSS auto-creates parents, but be safe):
async function put(url, doc){
  const go = () => authFetch(url,{method:'PUT',headers:{'Content-Type':'application/ld+json'},body:JSON.stringify(doc,null,2)})
  let r = await go()
  if(!r.ok && (r.status===404||r.status===409)){ await authFetch(CONTAINER,{method:'PUT',headers:{'Content-Type':'text/turtle'},body:''}).catch(()=>{}); r=await go() }
  if(!r.ok) throw new Error('save '+r.status)
}
```
For low-friction utilities, also cache to `localStorage` and sync to the pod
when signed in (see `split`, `weather`).

## 5. Intent bus (compose by type)

Apps hand off data by agreeing on *types*, not on each other. Vendor
[`intents.js`](https://github.com/solid-apps/intents), add `<script src="intents.js">`,
and:

```js
// declare in manifest.json:  "handles": ["webid","geo","url","text"]
window.intent.open('geo', `${lat},${lon}`, label)   // source → chooser → navigates
const i = window.intent.receive()                    // target on load → {type,value}|null
```
Types: `webid` (person), `geo` (`lat,lon`), `url`, `text`, `event`, `file`. The
chooser lists installed apps whose manifest `handles` the type (auto-opens if
one). Pure navigation → works on mobile, desktop and web.

## 6. UI conventions

- **Topbar**: `background:#2a3140; color:#fff;` brand (800 weight, .04em) + an
  `↑ root` link (`href="../../../"`, faint white).
- **favicon.svg**: a slate rounded tile + a **white** glyph, so it reads on the
  launcher's dark tile:
  ```svg
  <svg viewBox="0 0 24 24"><rect width="24" height="24" rx="5" fill="#2a3140"/>
    <g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
       transform="translate(2.4 2.4) scale(0.8)"> …feather-style glyph… </g></svg>
  ```
- A small **toast** for feedback; a **sign-in note** when logged out; pick one
  accent colour per app.

## 7. Distribution

- **Source of truth is `raw.githubusercontent`**, NOT jsDelivr — jsDelivr serves
  **stale `@gh-pages` files with a 200** and purges are unreliable. Fetch from
  `https://raw.githubusercontent.com/solid-apps/<app>/gh-pages/<file>`.
- **Optional app** (most apps): add an entry to
  [`solid-apps/bundles`](https://github.com/solid-apps/bundles)`/all.jsonld`
  (`app:spec`/`app:label`/`app:description`). The **store** app then lists it and
  installs it on-device (raw-fetch + authFetch `PUT` to `/public/apps/<name>/`).
  Do **not** add it to the Android default set unless it should ship by default.
- **Updating an installed app**: bump nothing — just push to `gh-pages`; the user
  taps **Update** in the store (raw-first, so no stale-CDN). The store skips
  dot-prefixed files (JSS 403s a `PUT` to them; keep your `.gitignore`).

## 8. Gotchas (learned the hard way)

- **Mobile drag**: pointer-drag handles need `touch-action: none`, or the
  browser steals the gesture as a scroll and fires `pointercancel` (this broke
  window-drag in chrome/win98).
- **Dot-prefixed files** (`.gitignore`, `.acl`) → `PUT` 403 on JSS; installers
  must skip them.
- **Self-update**: after updating an app's own code, **reload the page** — the
  running tab keeps the old cached `app.js` until refreshed.
- **CORS**: JSS reflects the request Origin + allows credentials, so cross-pod
  browser requests work; Open-Meteo etc. send `*`.
- **Reachability ceiling**: a `localhost` pod (the Android app) can't be reached
  by peers and can't authenticate to remote pods (its issuer is `localhost`).
  Cross-pod delivery/append-ACL needs the pod **hosted with a real domain +
  OIDC issuer**. External-data apps (weather/maps) work fine — they only make
  outbound calls.
- **GitHub trees API** (used for file listing) is 60 req/hr unauthenticated.

## 9. New-app checklist

1. `git init -b gh-pages`; add the 7 files (§1).
2. `index.html` from the template; `manifest.json` with `theme_color` + `handles`.
3. `app.js`: `authFetch`/`loggedIn`, render on `xlogin`/`xlogout`, store JSON-LD
   with an **established model** (§4).
4. Slate favicon (§6); a sign-in note; a toast.
5. `gh repo create solid-apps/<name> --public --source=. --push`.
6. Add to `bundles/all.jsonld` for store discovery.
7. Wire the intent bus if it shares/receives a type (§5).

