Safety snapshot of mobile work before merging upstream chat-v2: - Qortino AI: QDN pack discovery/download (Q-Share ids), external app storage, install validation, teach/report Q-Mail, PDF thumbs+zoom - Android share target "Quitter" with native ShareReceiver plugin - Categorized device saves (Images/Videos/Audio/Documents/Apps/GO state) - createNamedFile helper: cordova-plugin-file clobbers global File Co-authored-by: Cursor <cursoragent@cursor.com>
25 KiB
Qortal Q-Apps & QDN Websites — Developer & LLM Guide
Revision: 2026-07-14
Audience: Developers and AI assistants building Q-Apps and WEBSITE resources on Qortal
Scope: qortalRequest, QDN, packaging, Hub deployment, and common production patterns
Navigation
| § | Topic | Read when… |
|---|---|---|
| 0 | For LLMs | You are an AI loading this as context |
| 1 | Mental model | First time on Qortal / QDN |
| 2 | WEBSITE vs APP | Choosing service type or debugging 404 vs SPA routing |
| 3 | QDN coordinates | Publishing, fetching, or linking resources |
| 4 | Service types | Picking IMAGE / JSON / DOCUMENT / APP / … |
| 5 | qortalRequest |
Wallet, publish, encrypt, navigate |
| 6 | fetch & REST |
Public reads without wallet |
| 7 | Packaging | Zip layout, Vite, grey-screen fixes |
| 8 | Linking | qortal://, SPA routing, hash deep links |
| 9 | Hub integration | Auth, iframes, mobile, bridge patterns |
| 10 | Ecosystem | Cross-app identifiers and reference Q-Apps |
| 11 | Source repos | Gitea, GitHub, official docs |
| 12 | Pitfalls | Debugging checklist |
| 13 | Security | Trust model |
| 14 | Workflows | Step-by-step new Q-App / WEBSITE |
| 15 | Skeleton | Copy-paste starter |
| 16 | System prompt | Paste into another LLM |
0. For LLMs: start here
- Read §1–§2 before generating architecture — confusing WEBSITE and APP causes most routing bugs.
- Use
qortalRequestfor wallet, signing, publish, encrypt, and payments; use relativefetch()for anonymous public reads (§6). - Before publish/search code: read §3, §6, §12 (identifiers, search normalization, UTF-8 base64).
- Before shipping a zip: read §7 (flat
dist/,base: "./", no CDN). - When the user's Hub/Core version matters, verify action names in qortal.dev/docs/q-apps or Core
q-apps.js— do not invent parameters.
Default artifact: single-file HTML for small tools; Vite + flat zip for production SPAs.
Authority order when sources conflict:
- User's running Qortal Core + Hub version
- Q-Apps.md in Core
- qortal.dev/docs/q-apps
- This guide (patterns and pitfalls — not a byte-perfect API dump)
1. Mental model
| Term | Meaning |
|---|---|
| Qortal Core | Node software: chain validation, QDN storage, REST API |
| Hub / Mobile / Extension | User UIs that embed Q-Apps and inject qortalRequest |
| QDN | Qortal Data Network — resources indexed on-chain, payload mostly off-chain (SHA-256 verified) |
| Registered name | On-chain name tied to an account; required to publish from a Q-App |
| Resource | { name, service, identifier? } + data; latest publish wins for the same triple |
| Q-App | Static web app (HTML/JS/CSS), usually a zip with index.html at root, service APP |
| WEBSITE | Multi-file site on QDN, service WEBSITE — different routing (§2) |
Typical Q-App constraints: no private backend with secrets; identity is wallet address + optional name; ship assets in the zip (offline/CSP-safe).
2. WEBSITE vs APP
| WEBSITE | APP | |
|---|---|---|
| Missing file path | 404 | Falls through to index.html (SPA) |
| Use for | Visitor-facing static or multi-page sites | Interactive apps, builders, marketplaces, client routers |
| Examples | Portfolio site, published blog layout | Wallet UI, map tool, shop front-end |
Rule: Client-side router + app behaviour → APP. Mostly static pages with real 404s for bad URLs → WEBSITE.
SPA asset trap: Hub may open /render/APP/YourName without a trailing slash, so ./assets/index.js resolves against /render/APP/ and 404s. Fixes:
- Ensure a trailing slash on the app URL, or
- Inject
<base href>pointing at the app root (only when you understand the URL shape), or - Use absolute-to-root paths that match how your Hub serves the resource.
Rebuild and re-zip after any HTML or bundler config change.
3. QDN coordinates
Every QDN object:
name— publisher's registered name (controls update rights)service—IMAGE,JSON,APP,WEBSITE,DOCUMENT,BLOG_POST, …identifier— optional; distinguishes multiple resources per name+service
Default resource: main website or app for a name — omit identifier or use "default".
Shared identifiers: deterministic cross-app locations (e.g. avatar: service THUMBNAIL, identifier qortal_avatar + user name).
App namespaces: prefix identifiers with your app slug: myapp_config_v1, myapp_post_<id>. QDN is permissionless — validate payload schema; never trust identifier alone.
4. Service types
Condensed from qortal.dev. Sizes are per-service limits where documented.
| Service | Role | Typical limit |
|---|---|---|
JSON |
Small structured metadata | 25 KB |
DOCUMENT |
Larger text/JSON documents | 500 MB general cap |
DOCUMENT_PRIVATE (801) |
Encrypted documents | Private |
IMAGE / THUMBNAIL |
Media | 10 MB / 500 KB |
FILE / VIDEO / AUDIO |
Binaries | Large |
WEBSITE / APP |
Multi-file zips | APP 50 MB |
BLOG_POST / MAIL / MESSAGE |
Ecosystem apps | varies |
CHAIN_COMMENT |
Tiny on-chain payload | 239 B |
Single-file vs multi-file: JSON, IMAGE, etc. → one payload (data64 or file). WEBSITE, APP, GIF_REPOSITORY → many files; filepath required on fetch.
Publishing multi-file WEBSITE/APP from inside a Q-App: still limited or unavailable in many Hub builds — publish via Hub UI; fetch multi-file resources is supported. Verify your target version.
Private services (*_PRIVATE, numeric 801, etc.): encrypted for recipient; decrypt with wallet via DECRYPT_DATA or group variants.
5. qortalRequest bridge
Injected by Core/Hub into embedded pages. Returns a Promise — always try/catch. Timeouts are per-action in Core q-apps.js; fix payload size and batching rather than wrapping everything in long timeouts.
Host variables: window._qdnTheme (light|dark), window._qdnContext (e.g. gateway).
5.1 Feature detection
async function qr(payload) {
const req = window.qortalRequest ?? window.parent?.qortalRequest;
if (typeof req !== "function") throw new Error("Open inside Qortal Hub");
return req(payload);
}
Resolve the bridge from window, then parent, then top when running in nested iframes.
5.2 Identity (gesture-gated)
| Action | Purpose |
|---|---|
GET_USER_ACCOUNT |
Wallet address + public key — user approval; call from a button, not on first paint |
GET_ACCOUNT_NAMES |
Names for an address |
GET_ACCOUNT_DATA |
Account record |
GET_NAME_DATA |
Name owner and metadata |
Publish gate: user must own a registered name.
5.3 QDN read
| Action | Purpose |
|---|---|
FETCH_QDN_RESOURCE |
Load resource; add filepath for multi-file; encoding: "base64" when needed |
SEARCH_QDN_RESOURCES |
Discover resources; use prefix: true for identifier prefixes; minimal payload |
LIST_QDN_RESOURCES |
List by name/service (richer filters than search in some builds) |
GET_QDN_RESOURCE_URL |
URL for <img src> / fetch (e.g. /arbitrary/...) |
GET_QDN_RESOURCE_STATUS |
Build/load status (READY, percent loaded) — probe without full fetch |
GET_QDN_RESOURCE_METADATA |
Title, tags, category without full payload |
GET_QDN_RESOURCE_PROPERTIES |
filename, mimeType, size |
Search normalization: cores return array or { resources | results | entries | hits | data: [...] } — coerce to array. Unknown JSON keys on search → empty results; strip optional fields if search mysteriously fails.
5.4 QDN write
| Action | Purpose |
|---|---|
PUBLISH_QDN_RESOURCE |
Single file / data64; name must be owned |
PUBLISH_MULTIPLE_QDN_RESOURCES |
Batch publish — feature-detect; fewer approval rounds |
UTF-8 JSON → base64 — never raw btoa(JSON.stringify(obj)) on Unicode text:
function b64Utf8(obj) {
const bytes = new TextEncoder().encode(JSON.stringify(obj));
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
5.5 Navigation & Hub UI
| Action / pattern | Purpose |
|---|---|
LINK_TO_QDN_RESOURCE |
Open QDN resource in Hub |
OPEN_NEW_TAB |
New tab with URL or qortal:// link |
OPEN_PROFILE |
Open peer profile |
SET_TAB (via postMessage) |
Hub tab switch — { action: 'SET_TAB', requestedHandler: 'UI', payload: { service, name, identifier?, path? } } |
QDN_RESOURCE_DISPLAYED |
Tell Hub what resource is shown (address bar / copy-link) — { action, service, name, path? } |
SET_TAB_NOTIFICATIONS |
Badge count on tab |
NAVIGATION_HISTORY |
Back/forward integration (Hub-dependent) |
5.6 Groups, polls, payments
| Action | Notes |
|---|---|
LIST_GROUPS / JOIN_GROUP / GET_GROUPS_WITH_MEMBER |
Group membership |
CREATE_POLL / VOTE_ON_POLL |
pollOptions must be array; pollName must be unique |
SEND_COIN / TRANSFER_ASSET / SEND_PAYMENT |
User approval; confirm params on target Hub version |
SIGN_TRANSACTION |
Hub-signed tx, then broadcast via REST /transactions/process |
SEARCH_TRANSACTIONS |
Payment matching — message field often missing; match by reference/type |
5.7 Encryption
| Action | Purpose |
|---|---|
ENCRYPT_DATA |
file or base64; optional publicKeys for group |
DECRYPT_DATA |
Ciphertext base64 + counterparty publicKey |
ENCRYPT_QORTAL_GROUP_DATA / DECRYPT_QORTAL_GROUP_DATA |
Group-scoped encryption |
Use separate identifier namespaces for encrypted blobs.
5.8 Lists (local UI state)
GET_LIST_ITEMS, ADD_LIST_ITEMS, DELETE_LIST_ITEM — per-user lists in Hub, not global QDN.
5.9 Mobile (Qortal Go)
| Action / URL | Purpose |
|---|---|
SAVE_FILE |
Save blob + filename natively — mobile WebViews often ignore anchor/blob downloads |
/arbitrary/…?attachment=true |
Native download listener on some mobile builds |
Detect mobile via user-agent when choosing download strategy.
5.10 Chain & misc
FETCH_BLOCK, GET_BALANCE, GET_BLOCK_HEIGHT, GET_PRICE, DEPLOY_AT, GET_AT, notifications permission actions — see qortal.dev for the full list.
6. fetch and REST
Inside Hub, relative fetch("/names/foo") hits the user's node API — good for public reads (blocks, names, arbitrary endpoints).
Limitation: fetch does not know the logged-in wallet; personalized or signing flows need qortalRequest.
Do not hardcode http://127.0.0.1:12391 in shipped apps.
QDN search modes (REST): mode=ALL vs mode=LATEST — wrong mode → duplicates or wrong counts.
Reference: api.qortal.org/api-documentation — cross-check field names when debugging bridge vs REST.
Local preview (varies by Core): http://localhost:12391/render/APP/<Name>?preview=true
7. Packaging and publishing
7.1 Zip layout (non-negotiable)
my-app.zip
├── index.html ← at zip root
├── manifest.json
├── assets/
│ ├── index-xxxxx.js
│ └── index-xxxxx.css
└── … (fonts, images, workers)
- Zip contents of
dist/, not adist/folder wrapper. manifest.json:"main": "index.html", title, version, category.- Paths:
./assets/..., never/assets/.... - Avoid
<base href>unless you are deliberately fixing APP path resolution (§2). - Vite:
base: "./". - Ship fonts/JS in-bundle — no required CDN.
- Case-sensitive paths; prefer
[a-z0-9._-].
7.2 Workflow
npm run build→dist/(or equivalent)(cd dist && zip -r ../MyApp.zip .)- Hub → load zip → Preview before QDN publish
- Publish service
APP(orWEBSITEvia Hub for end-user sites) - Open
qortal://APP/<Name>orqortal://WEBSITE/<Name>/
7.3 Grey screen / eternal “Loading…”
| Cause | Fix |
|---|---|
assets/index-*.js 404 |
Flat zip, base: "./", rebuild |
Absolute /assets/ paths |
Relative ./assets/ |
| Stale hashed files | Full rebuild + fresh zip |
crossorigin on scripts |
Some Hub builds fail script load — remove if needed |
8. Linking and deep links
8.1 qortal:// protocol
qortal://{service}/{name}/{identifier?}/{path?}
Examples:
qortal://WEBSITE/MySite— homeqortal://WEBSITE/MySite/gallery— sub-pageqortal://APP/MyQAppqortal://THUMBNAIL/MyName/qortal_avatar
Use default in path when you need explicit no-identifier semantics.
8.2 SPA routing inside Hub
Published apps often run under paths like /render/WEBSITE/<name>/ or /render/APP/<name>/. Use pathname segments for sub-pages and history.pushState / popstate for in-app navigation without leaving the Hub.
Hash deep links (optional): SPAs may use location.hash for anchors (e.g. #section-id). If you use multiple hash prefixes (e.g. #folder-… and #folderdir-…), parse longest match first — naive startsWith('folder-') breaks when one prefix is a substring of another.
Preserve intentional hash fragments when calling history.replaceState for pathname cleanup.
8.3 Programmatic navigation
Prefer LINK_TO_QDN_RESOURCE or SET_TAB postMessage for cross-app opens.
9. Hub integration
9.1 Authentication
GET_USER_ACCOUNTonly works inside Hub (or via a bridge stub that forwards toparent).- Opening built
index.htmlin a normal browser cannot complete wallet auth. - If
parent === topand only a stub exists, wallet calls fail — user must open the app from Hub. - Do not auto-call
GET_USER_ACCOUNTon page load; use an explicit Connect / Authenticate button. - Embedded Q-Apps in iframes: parent page may need a message relay that forwards
qortalRequestto the Hub; if inner auth still fails, open the target in a new Hub tab.
9.2 Bridge wrapper pattern
Centralize all bridge calls in one module. Typical resolution order:
window.qortalRequest → parent.qortalRequest → top.qortalRequest
Wrap with short-timeout and long-timeout helpers (publishes and payments often need longer limits). Always try/catch.
9.3 Preview mode
if (typeof qortalRequest !== "function" && typeof window.parent?.qortalRequest !== "function") {
// Show "Preview mode" — mock reads only; never fake successful payments
}
9.4 Hub copy-link / address bar
Multi-page WEBSITE apps can notify the Hub what sub-path is displayed via QDN_RESOURCE_DISPLAYED, so copy-link matches the page the user sees. Optionally maintain <link rel="canonical"> with the matching qortal://WEBSITE/<name>/<slug> form.
10. Ecosystem and conventions
Well-known reference Q-Apps on GitHub (study zip layout and qortalRequest usage):
| Project | Repo | Typical services |
|---|---|---|
| Q-Mail | Qortal/q-mail | MAIL, MAIL_PRIVATE |
| Q-Share | Qortal/q-share | FILE, DOCUMENT |
| Q-Tube | Qortal/q-tube | VIDEO, BLOG_POST |
| Q-Shop | Qortal/q-shop | STORE, PRODUCT, payments |
Cross-app conventions:
| Identifier / pattern | Used for |
|---|---|
qortal_avatar + THUMBNAIL |
User avatar image |
myapp_ prefix on identifiers |
App-scoped JSON search |
BLOG_POST + Q-Blog identifiers |
Blog content blocks |
FILE + qfile_… identifiers |
File attachments in blog/mail apps |
Multi-app ecosystems: Some products publish a WEBSITE for visitors plus separate APP tools plus JSON/DOCUMENT indexes for discovery. Use versioned schema strings in JSON (e.g. myapp-index-v1) and document identifiers in your app's README.
11. Source repos
11.1 Gitea (community)
| URL | Use |
|---|---|
| gitea.qortal.link | Community Gitea — browse and host Qortal-related projects |
Search Gitea for Q-Apps, Hub forks, and site builders. Clone URLs follow https://gitea.qortal.link/<user>/<repo>.git.
11.2 GitHub (official org)
| Repo | Use |
|---|---|
| Qortal/qortal | Core, Q-Apps.md, q-apps.js (timeouts, actions) |
| Qortal/Qortal-Hub | Desktop Hub |
| Qortal/qapp-core | Optional npm helpers |
| Qortal/q-mail, q-share, q-tube, q-shop | Reference apps |
11.3 Official documentation
| URL | Use |
|---|---|
| qortal.dev/docs/q-apps | qortalRequest reference (authoritative for actions) |
| api.qortal.org/api-documentation | REST / node API |
| Q-Apps.md | Protocol: routing, resources, linking |
12. Pitfalls
| Problem | Fix |
|---|---|
| Grey screen / Loading forever | Network tab: assets/index-*.js must be 200; flat zip, base: "./" |
/assets/... 404 on APP |
Trailing slash / <base href> / relative paths |
Auto GET_USER_ACCOUNT on load |
Use Connect button |
btoa(JSON.stringify) on Unicode |
§5.4 UTF-8 safe base64 |
SEARCH_* not an array |
Normalize to array |
Extra fields on search → [] |
Minimal search payload |
JSON service > 25 KB |
Use FILE / DOCUMENT |
| Unprefixed identifiers | appslug_type_id |
| Multi-file publish from Q-App | Use Hub UI; verify version |
CREATE_POLL options as string |
Array of { optionName } |
| Hardcoded localhost API | Relative fetch only |
| External CDN for core JS | Bundle in zip |
| Unsanitized QDN HTML → XSS | Escape / sanitize |
Promise.all on hundreds of fetches |
Bounded concurrency |
| Hub iframe wallet isolation | Open in new tab / message relay |
| Mobile blob download | SAVE_FILE or ?attachment=true |
| Overlapping hash prefixes | Parse longest prefix first |
| Payment succeeded, notify failed | Treat payment and notification as separate steps |
13. Security
- Never collect seed phrases or private keys.
- Treat all QDN JSON as untrusted — validate schema and sizes.
- Explain publishes, payments, and decrypt actions before calling
qortalRequest. - Rate-limit retries; distinguish user cancel vs technical errors.
14. Workflows
14.1 New Q-App
- Choose stack: single HTML vs Vite/React (or similar).
- Scaffold
index.html,manifest.json, relative paths only. - Central bridge module for all
qortalRequestcalls. - Connect button →
GET_USER_ACCOUNT→GET_ACCOUNT_NAMES→ gate publish. - Design identifier scheme + JSON schemas.
- Reads:
FETCH_QDN_RESOURCE/SEARCH_QDN_RESOURCESwith normalization. - Writes:
PUBLISH_QDN_RESOURCEwith UTF-8data64. - Test: root URL, deep link, no-name wallet, publish propagation delay.
- Build → flat zip → Hub Preview → publish
APP.
14.2 New WEBSITE (visitor site)
- Static HTML/CSS/JS, relative paths.
- Publish via Hub under an owned name → service
WEBSITE. - Link:
qortal://WEBSITE/<Name>/... - If the product needs SPA routing and heavy
qortalRequestuse throughout, considerAPPinstead (§2).
14.3 Inter-app integration
- Define stable identifier prefixes and JSON schema versions.
- Publish discovery metadata as small JSON or DOCUMENT resources.
- Read partner apps via
SEARCH_QDN_RESOURCESwithprefix: true. - Open partner resources with
qortal://links orLINK_TO_QDN_RESOURCE. - Never assume identifier alone proves origin — validate payload shape.
15. Skeleton
Minimal single-file Q-App (teaching reference):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Q-App</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; }
button { min-height: 44px; padding: 0 1rem; }
pre { white-space: pre-wrap; word-break: break-word; }
.warn { color: #b45309; }
</style>
</head>
<body>
<h1>My Q-App</h1>
<p id="env" class="warn"></p>
<button type="button" id="connect">Connect</button>
<button type="button" id="load" disabled>Load demo JSON</button>
<pre id="out"></pre>
<script>
function qr(payload) {
const req = window.qortalRequest ?? window.parent?.qortalRequest;
if (typeof req !== "function") throw new Error("Open inside Qortal Hub");
return req(payload);
}
function b64Utf8(obj) {
const bytes = new TextEncoder().encode(JSON.stringify(obj));
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
const env = document.getElementById("env");
const out = document.getElementById("out");
const btnConnect = document.getElementById("connect");
const btnLoad = document.getElementById("load");
if (typeof qortalRequest !== "function" && typeof window.parent?.qortalRequest !== "function") {
env.textContent = "Preview: open inside Qortal Hub for live APIs.";
}
btnConnect.onclick = async () => {
out.textContent = "";
try {
const acc = await qr({ action: "GET_USER_ACCOUNT" });
const names = await qr({ action: "GET_ACCOUNT_NAMES", address: acc.address, limit: 10, offset: 0 });
const primary = Array.isArray(names) && names[0]?.name ? names[0].name : null;
btnLoad.disabled = !primary;
out.textContent = JSON.stringify({ address: acc.address, primaryName: primary }, null, 2);
} catch (e) {
out.textContent = String(e?.message ?? e);
}
};
btnLoad.onclick = async () => {
try {
const acc = await qr({ action: "GET_USER_ACCOUNT" });
const names = await qr({ action: "GET_ACCOUNT_NAMES", address: acc.address, limit: 1, offset: 0 });
const name = names?.[0]?.name;
if (!name) throw new Error("No registered name to publish under.");
const id = "myapp_demo_" + Date.now();
await qr({
action: "PUBLISH_QDN_RESOURCE",
name,
service: "JSON",
identifier: id,
data64: b64Utf8({ v: 1, hello: "world", ts: Date.now() }),
});
const raw = await qr({ action: "FETCH_QDN_RESOURCE", name, service: "JSON", identifier: id });
out.textContent = typeof raw === "string" ? raw : JSON.stringify(raw, null, 2);
} catch (e) {
out.textContent = String(e?.message ?? e);
}
};
</script>
</body>
</html>
16. LLM system prompt
Paste as system instructions for another model:
You build Qortal Q-Apps: static web apps using qortalRequest for wallet/QDN/signing and relative fetch() for public Core reads.
Official API: https://qortal.dev/docs/q-apps — verify action names against the user's Hub/Core version.
Protocol reference: https://github.com/Qortal/qortal/blob/master/Q-Apps.md
REST reference: https://api.qortal.org/api-documentation/
Community repos: https://gitea.qortal.link and https://github.com/qortal
Rules:
- No <base href> unless deliberately fixing Hub APP path resolution.
- Relative paths (./assets/...) and bundler base "./".
- index.html at zip root; ship fonts/JS in-bundle; no required CDN.
- GET_USER_ACCOUNT only after explicit user gesture. No registered name → explain publish cannot proceed.
- Prefix QDN identifiers with app slug; validate all fetched JSON; UTF-8 safe base64 for publishes.
- Normalize SEARCH_QDN_RESOURCES to an array; minimal search payload if results empty.
- JSON service ≤25 KB; large data → FILE/IMAGE/DOCUMENT.
- try/catch all qortalRequest; never handle seeds/private keys.
- Preview mode when bridge missing — mock reads only, never fake payments.
- WEBSITE = 404 on bad paths; APP = SPA fallback to index.html.
- Mobile: SAVE_FILE for blob downloads when anchor download fails.
Maintenance
- Bump revision date when Core/Hub changes
qortalRequestcontracts materially. - Re-check multi-file publish support after major Hub releases.
- Keep links to qortal.dev, api.qortal.org, and gitea.qortal.link current.
End of guide.