apps
Skill4 starsChanged 4 days agoMITView on GitHub
Make, change, reorder or remove a Loom app — a small program with its own page in Loom's Apps grid (a dashboard, tracker, tool), optionally with a server and a scheduled job, that runs without a model. Use when the user asks for an app, a dashboard or page they keep coming back to, something "every N hours" that should just be looked at ("做个 app", "做成 app", "看板", "dashboard", "tracker", "每天自动更新"), or to fix or change an existing app.
- Reads credentials
2 other projects use a version of this.
Adopted by
Most recently taken up by
- belt-sh/skillstheir own version, first seen Sep 27, 2026
- agenkin/telemetrydeck-analyticstheir own version, first seen Apr 18, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- agenkin/telemetrydeck-analytics★ 2same name
- belt-sh/skills★ 2same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository5 other files it gives its agents.
- loom-nodesSkill
- browserSkill
- cronSkill
- sessionsSkill
- slidesSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 52
Write web/index.html before the first put.
- Very specific: line 57
After changing server.ts or a job's script, put again with the same values: that restarts it on the new code.
- Very specific: line 118
Use relative URLs only — fetch("api/todos"), fetch("data.json").
- Very specific: line 131
Only if the page saves or computes.
- Distinctive: line 190
Before telling the user it is done:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
apps: programs distilled out of a conversation
An app turns work you would otherwise redo every time into a plain program: the model builds it once, and from then on it runs with no model at all — a timer fetches the data, a page shows it, a small server saves what the user types. That is the point: no tokens per use.
No AI inside an app unless the user explicitly says this app is about calling a model. No LLM calls, no Bedrock, no "summarise with Claude" in a job or server — data in, code, data out. If a step seems to need judgment, ask the user how they want it decided and write that rule down as code.
The pieces
An app lives in ~/.loom/apps/<name>/ (name: lowercase letters, digits, -;
it is also the URL). Every piece but the page is optional:
~/.loom/apps/<name>/
web/index.html the page (required); web/ is served as it is
web/favicon.svg the page's favicon, which is also its icon in the grid
server.ts a server, if the page saves or computes anything
jobs/… a job, if data must be fetched on a schedule
data.db, web/data.json … its data, wherever the app likes
.run/ sockets; Loom's, do not touchWhat Loom knows about it — title, order, server and job commands, schedule —
is in ~/.loom/apps.json. Never edit that file, and never write systemd
units yourself: go through Loom.Apps over bin/loom rpc, which writes the
registry, writes and restarts the units, and updates the user's grid live.
Calling it
B="$(dirname "$LOOM_ENV_FILE")/_build/prod/rel/loom/bin/loom"
$B rpc 'IO.inspect(Loom.Apps.list())'
$B rpc 'IO.inspect(Loom.Apps.put("aws-bill", %{title: "AWS Billing", job: "python jobs/refresh.py", schedule: "*-*-* 00/4:00:00"}))'
$B rpc 'IO.inspect(Loom.Apps.put("todo", %{title: "Todo", server: "node server.ts"}))'
$B rpc 'IO.inspect(Loom.Apps.run("aws-bill"))' # the job, now, in the background
$B rpc 'IO.inspect(Loom.Apps.move("todo", 1))' # 1 is first
$B rpc 'IO.inspect(Loom.Apps.delete("todo"))' # units go, the directory to apps/.trash/
$B rpc 'IO.inspect(Loom.Apps.key("token-billing"))' # {:ok, key} for a program; see below- Wrap every call in
IO.inspect: rpc prints nothing otherwise. - Write
web/index.htmlbefore the firstput; it is refused without one. putcreates or changes. Keys:title(default: the name),auth,server,idle(how long the server may sit unused before it is stopped, default"10min"),job,schedule. A key left out keeps its value;nilclears it. A new app goes last in the grid.- After changing
server.tsor a job's script,putagain with the same values: that restarts it on the new code. - Returns
{:ok, app}— withurl, the signed path its page opens under — or{:error, reason}.
Who can reach it
Settle this before writing any code — it decides whether the app needs accounts. Ask the user who will use it, if they have not said:
- Only the user — a tool or dashboard for themselves: private, the
default. They open it from their Apps grid (the signed
url, 30 days; on an apps origin it turns into a cookie for the app, renewed on every visit, so a bookmark or an installed app keeps working). - A few others, for a while, all the same to the app — a dashboard to
show family for a month: still private; the user sends them the
url. Anyone holding it is the user as far as the app knows, it expires after 30 days and is re-sent then, and no one person can be shut out. - Other people the app must tell apart — a group, colleagues, customers:
who said what, who may do what, staying for good: public, with accounts
built in from the start. Loom checks nothing for a public app, so the server
owns login: a users table in sqlite, passwords hashed (
crypto.scrypt), a random session token returned at login and sent back asAuthorization: Bearer, rate-limited attempts; the page keeps the token in localStorage. Designing as private and discovering later that people must log in means rewriting the app.
Public only with the user's explicit yes — say that anyone on the internet can reach it, and that its own login is then its only guard. Never on your own.
Programs are separate from people. For one that pushes to or pulls from a
private app for good — another machine's job, a script — mint a key with
Loom.Apps.key(name): it returns {:ok, key} once and keeps only a hash. The
program sends Authorization: Bearer <key> to
<apps origin>/apps/<name>/api/… (the origin of the url from list), with
no token in the path.
Calling key again replaces it; Loom.Apps.key(name, nil) revokes it. Hand
the key over as a file with mode 600 or a secret store, never in web/, a
page, or chat output beyond what the user needs.
The page
- Plain HTML + JS is the default. A React/Vite build works too: build into
web/, withbase: "./"so asset URLs are relative. - It runs on the apps' own origin, the one in the
urlfromput(LOOM_APPS_URL, e.g.https://…-apps.…; without ithttp://localhost:5000, this machine only), never on Loom's. Cookies, localStorage and PWA install work like on any site. All apps of this Loom share that origin, so prefix storage keys with the app's name, and keep nothing there another app must not read. What must survive for good goes to the server or the data a job writes. - Installable:
web/manifest.webmanifestwithname,short_name,"id": "./","start_url": "./","scope": "./","display": "standalone"andicons(favicon.svgwith"sizes": "any", plus a 512×512 PNG), linked as<link rel="manifest" href="manifest.webmanifest">, and for iPhone<link rel="apple-touch-icon" href="icon-180.png">(a 180×180 PNG; iOS ignores the manifest's icons and draws a letter instead). A browser fetches these without the app's cookie, so Loom serves the manifest, the icons it lists and the favicon to anyone; keep them to name and looks. - Use relative URLs only —
fetch("api/todos"),fetch("data.json"). The page lives under/apps/<name>/; an absolute/api/...is not the app's. - CDNs are allowed; for charts read the
datavizskill first. - One icon for both: give the page a favicon,
web/favicon.svg, linked as<link rel="icon" href="favicon.svg">(relative). The grid shows whatever that link points at (elsefavicon.svg/.png/.icoinweb/, else the title's first letter). Draw it as a full-bleed square — a colour or gradient edge to edge with a simple white glyph — and no rounded corners of its own: Loom rounds it, and the tab shows it small, so keep the glyph bold. - Show the data's age ("更新于 …") and, when the last refresh failed, the error.
The server
Only if the page saves or computes. Node 24 + TypeScript, run directly — no
build, no package.json, no node_modules:
import http from "node:http"
import { DatabaseSync } from "node:sqlite" // built in; synchronous, which is fine here
const db = new DatabaseSync("data.db") // cwd is the app's directory
http.createServer(handler).listen(process.env.APP_SOCKET)- Listen on
process.env.APP_SOCKET(a unix socket), never a port. - It receives exactly what the page sent under
api/: the page'sfetch("api/todos?x=1")arrives asGET /api/todos?x=1. Onlyapi/*reaches it; everything else is a file fromweb/. - Forwarded: method, query, body,
content-type,accept, for a public appauthorization, and on an apps origin the app's own cookies (set-cookiethem withPath=/apps/<name>/). A private app gets no auth at all — Loom has checked the link, cookie or key already. Back: status, headers, body. - HTTP/1.0, one request per connection, buffered, 30 s at most: plain JSON request/response only — no SSE, no WebSocket, no streaming.
- Files are not read and returned. For a photo, a video, a download —
anything not small — check what you must, then answer with only a header:
res.writeHead(200, {"x-loom-file": "media/12/orig.mov"}); res.end(). The path is relative to the app's directory and must stay inside it. Loom sends the file from disk, withRange(a phone plays no video without it) and the content type from the extension unless you set one; other headers you set, such ascontent-disposition, go along. - It is started on the first request (~0.1 s) and stopped after
idle, so keep no state in memory that sqlite does not also have. 256 MB memory cap.
The job
For data that must be fetched — an API, AWS, a site. Python (~/.venv, with
pandas, boto3, requests…) is the default; anything on the PATH works.
scheduleis a systemd calendar expression, in UTC (Beijing = UTC+8):"hourly","*-*-* 00/4:00:00"(every 4 h),"*-*-* 01:00:00"(09:00 Beijing). Check one withsystemd-analyze calendar '<expr>'. A run missed while the host was down happens at boot.- It runs in the app's directory. Write data atomically (a temp file, then rename) so the page never reads half a file.
- On failure keep the last good data, record the error and its time next to
it (e.g.
"error","error_at"indata.json), and exit non-zero. The page shows that error; the user then asks an agent to look. Nothing wakes a model by itself. - Run now, not at the next tick: the server can start the job when the page
hands it work,
systemctl --user start --no-block app-<name>-job.service(asLoom.Apps.rundoes). A start while the job is running is dropped, so such a job loops until nothing is left. - Handing work to an agent — only when the user designed the app to (a
finished batch to be filed by a Loom session, say): write
{"title": "…", "prompt": "…"}to a JSON file and runloom rpc 'IO.inspect(Loom.Agent.new_session_file("/abs/path/req.json"))'(loomis on the job's and server's PATH). It starts a session in the sidebar and returns{:ok, id}. Never splice text into rpc code: any#{…}in a title someone typed runs as Elixir.
Checking your work
Before telling the user it is done:
export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
systemctl --user list-units --all 'app-<name>*' --no-legend | cat
journalctl --user -u app-<name>-job -n 20 --no-pager # a job's output
journalctl --user -u app-<name>-server -n 20 --no-pager # a server's
curl -s --unix-socket ~/.loom/apps/<name>/.run/http.sock http://x/api/… # the server, as Loom calls itThen open the page itself with Playwright at url (from put/list), light
and dark, and look at a screenshot.
Tell the user it is in the sidebar's Apps. Do not hand out the URL unless asked: it is a credential (30 days, this app only), and Loom opens apps itself.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

