@descix/app-sdk
v0.9.0
Published
DeSciX App Shell - init-to-READY logic, core API, Powch integration, dev proxy config
Readme
@descix/app-sdk
Core package for all DeSciX web apps. Provides init-to-READY app shell logic, auth, API surface, Powch bridge integration, and dev proxy/config.
Hosting an app inside the shell? The contract is The App Shell API — DeSciX.view to choose your layout, DeSciX.chat to show the model something, DeSciX.powch for identity and wallet, and window.DeSciX_Actions for what the shell calls back into. Include DeSciXAppSDK.js and use DeSciX.…: it detects which window carries the bus, so you never write window.parent. or window.top.
The shell resolves which app it is serving at RUNTIME, from a binding served at /__descix/app-binding.json on its own origin, so one bundle boots as the store and as your app standalone with no rebuild. An app that builds itself standalone declares it at its own mount: <AppShell appId="my-app" standalone>.
Installation
npm install @descix/app-sdkExports
| Export | Description |
|--------|-------------|
| . | AppShell, AppContext, AppProvider, useAppContext, Api, ErrorBoundary, LoadingWidget, DebugWidget, CodeSiteWidget, ChatWidget, PowchSideBarWidget, PowchBridgeProvider, usePowchBridge, PowchClient, toSignableTypedData, TypedDataError, useDeSciXView, VIEW_MODES, initialViewFor, seedView, viewAvailable, requestView, VIEW_OUTCOMES, SHARE_CONTRACT, parseShareJson, validateShareJson, SHARE_JSON_FILE, publishChatApi, retractChatApi, CHAT_CONTEXT, CHAT_CONTEXT_DATA, CHAT_SCRIPT_API, CHAT_CONTEXT_OUTCOMES, normalizeChatContext, setChatContext, clearChatContext, publishBridgeMember, retractBridgeMember, getBridge, BRIDGE_READY_EVENT, pendingIntent (namespace), shareLink (namespace), PENDING_INTENT_KINDS, PENDING_INTENT_STATUS, createIntentResumer |
| ./dev | createViteProxyConfig, createViteServerConfig, getViteHttpsConfig, resolveGatewayPort, resolveServeBinding, resolvePowchUrl, resolveDevCertOptions, buildWorkspaceProducts |
| ./share | SHARE_JSON_FILE, SHARE_CONTRACT, parseShareJson, validateShareJson (no DOM, no Node-only import; imports the view list from appView.js) |
| ./chat-context | the page-context contract and its server half: CHAT_CONTEXT, CHAT_CONTEXT_DATA, normalizeChatContext, renderPageContextBlock (no DOM at load, no Node-only import) |
| ./pending-intent | a deep link that waits across sign-in: PENDING_INTENT_KINDS (device-login, mcp-connect, powch-offer), capture, peek, consume, createIntentResumer, PENDING_INTENT_STATUS, PENDING_INTENT_MAX_AGE_MS. sessionStorage, per tab and origin; resumed once when signed in (no React, no network) |
| ./share-link | the share link <page>?ref=<custom_id>: REF_PARAM, webPageMintParams + requireShareUrl (mint with generate_referral_code), capturePendingRef, storePendingRef, claimPendingRef (process_deep_link { custom_id }; the server derives the claimant and the referrer), CLAIM_OUTCOMES. AppShell captures ?ref at mount and claims it once signed in; no app code needed (no DOM, no Node-only import) |
| ./powch-client | PowchClient (for third-party sites) |
Usage
AppShell + Children
import { AppShell } from '@descix/app-sdk';
import { PowchAppProvider, PowchStandaloneApp } from '@powch/react';
function PowchApp() {
return (
<AppShell appId="powch" config={{ powch: { brand: { name: 'Powch', logo: null } } }}>
<PowchAppProvider config={{ brand: { name: 'Powch', logo: null } }}>
<PowchStandaloneApp />
</PowchAppProvider>
</AppShell>
);
}The shell handles init, auth, and providers. When appState === READY, it renders children.
Views
An embedded app picks its layout with DeSciX.view.set('CodeSite'), DeSciX.view.set('SplitView') (default), DeSciX.view.set('Chat') or DeSciX.view.set('NoChrome'); DeSciX.view.set('NoChrome') is the way to a chromeless phone view (no top bar, no padding, no chat; the app fills the viewport). The CodeSite iframe's allow="fullscreen" is a different thing: it delegates the browser Fullscreen API, which the app may call on top of any view. Full reference: APP_SHELL_API.md.
requestView: ask for a view and learn what happened
requestView(mode, { fallback }) finds the shell that hosts your page at any frame depth and returns { outcome, mode, requested, bridge, reason }. outcome is one of VIEW_OUTCOMES:
| outcome | meaning |
|---------|---------|
| requested | a listening shell set mode |
| fallback | the shell's view.MODES lacks mode; it set your fallback |
| unoffered | nothing was set: no shell (bridge: 'standalone'), a framed page with no readable shell (bridge: 'embedded-no-bridge'), no view member, no listening host, or the shell offers neither mode nor fallback |
| view-no-modes | you gave a fallback, and the shell publishes no view.MODES to choose by |
| set-threw | the shell's view.set() threw; reason carries its message |
import { requestView } from '@descix/app-sdk';
const { outcome } = requestView('NoChrome', { fallback: 'CodeSite' });It throws only for a mode or fallback that is not a VIEW_MODES value (requestView('FullScreen') is "not a view") and for a shell that breaks the view contract (err.code === 'VIEW_CONTRACT_VIOLATED'). Where the page runs is never an error.
A plain-script site gets the same function from the generated DeSciXAppSDK.js: DeSciX.requestView(...), with DeSciX.VIEW_OUTCOMES and DeSciX.VIEW_MODES.
setChatContext: tell the chat pane what the page has open
A page hands the shell's chat pane its context so the reader can ask about the page without leaving it. One call, three fields; last write wins:
const { outcome } = DeSciX.setChatContext({
sourceUrl: location.href, // required: names the page
data: { title: 'The EGPT paper', kbNames: ['Corpus', 'Sources'], docId: 'corpus:3e1d5aa3',
sources: [{ title: 'Rota, Combinatorial Theory', url: 'https://example.org/rota' }] },
script: "chat.button('Explain this page', () => chat.ask('Explain this page.'));",
});datais any JSON object, up to 32000 characters as JSON. The platform reads three keys (CHAT_CONTEXT_DATA):title(shown in the pane, up to 200 characters),kbNames(the app's knowledge bases each turn searches, up to 8; the first one's instructions shape the reply) anddocId(the open document's id in the first knowledge base; its whole text is added to each turn, and a turn whose document cannot be loaded is refused). Every other key (a route, a selection, the page's cited sources) goes to the model as page data.script(up to 64000 characters) runs in the chat pane's window, which is the shell, on the same origin as your page. It getschatwith exactlyCHAT_SCRIPT_API:root(the element it draws into, emptied before each run),button(label, onClick),ask(text)(submits a chat turn; metered),report(error),sourceUrlanddata. It calls the platform through the shell'sDeSciXbus (DeSciX.call). It reaches Powch only through the existingDeSciX.powchbridge, whose wallet acts prompt the user.- The pane shows one line saying what rides each turn (
Reading: The EGPT paper · searching Corpus, Sources). A script that throws, or achat.buttonhandler that throws or rejects, is shown in the pane namingsourceUrl. outcomeis one ofCHAT_CONTEXT_OUTCOMES:attached,cleared,unavailable(no shell, no chat member, or a shell older than 0.7.0;bridgeandreasonsay which) orrefused(the shell'schat.setContextthrew). Where the page runs is never a throw; a malformed argument throwsCHAT_CONTEXT_INVALID, naming the field (unknown field title; the fields are sourceUrl, data, script (put anything else inside data)).- The context belongs to the app that set it: the shell reads it only on that app's face, and drops it when the app or its site changes, when the CodeSite unmounts, and when the page that set it unloads (
pagehide).DeSciX.clearChatContext()drops it explicitly.
To open the pane on demand, ask for the view: DeSciX.requestView('SplitView'). Full reference: APP_SHELL_API.md.
share.json: the link-preview contract
A site bundle may ship share.json beside its index.html; the edge rewrites the served page head from it. SHARE_CONTRACT is the field table, and the edge, descix serve and descix site upload all run parseShareJson: title and description are required non-empty strings, icon, og_image, twitter_card and apple_touch_icon are optional strings, twitter_card defaults to summary_large_image, and default_view is optional (below). A present optional field that is not a non-empty string is refused.
import { parseShareJson } from '@descix/app-sdk/share';
const share = parseShareJson(text); // the full bag, absent optional fields null; or throws, e.g.
// "share.json: description is required (a non-empty string)"descix site upload runs this check on a shipped share.json and refuses the whole upload when it fails.
default_view: the view your app starts in
default_view in share.json names the shell view your app's face paints FIRST, one of VIEW_MODES: CodeSite, SplitView, Chat, NoChrome. Any other value is refused by name (share.json: default_view 'FullScreen' is not a view; one of CodeSite, SplitView, Chat, NoChrome). Absent, the shell starts in SplitView.
{ "title": "EGPT", "description": "Electronic Graph Paper Theory", "default_view": "CodeSite" }The edge copies it into the app binding (/__descix/app-binding.json, defaultView) on {app}.descix.net (PROD; {app}.{env}.descix.net on dev and demo), and descix serve does the same from a static site's share.json (a site served by a dev server, site.port, is not read; the serve banner's First view: line says which applied). The shell seeds its view from the binding (initialViewFor(binding)) before it renders your app, so there is no SplitView frame first. The app may still change the view at any time with DeSciX.view.set or requestView. On the store (descix.net) no binding is served and the shell starts in SplitView.
PowchClient (Third-Party Sites)
import { PowchClient } from '@descix/app-sdk/powch-client';
// bridgeUrl is REQUIRED and has no default. It decides where your users type a
// passkey and unlock a wallet, so the SDK will not guess it — an unset origin
// throws with the ways to set it. Point it at the environment you mean.
const powch = new PowchClient({ bridgeUrl: import.meta.env.VITE_POWCH_APP_URL });
powch.createButton(document.getElementById('powch-button'), { toggle: true });Signing methods: signTransaction({ tx, address? }), signMessage({ message, address? }), and
signTypedData({ typedData, address? }) -> { signature }. typedData is the full EIP-712 object
{ domain, types, primaryType, message } (e.g. Coinbase CDP's permit2.eip712); the client drops
types.EIP712Domain (ethers v6 rejects it) and sends { domain, types, value }. A missing field or a
primaryType that is not a key of types throws TypedDataError before anything is sent.
Dependencies
The app-sdk is self-contained. It bundles:
- MUI (@mui/material, @mui/icons-material, @emotion/react, @emotion/styled)
- React, Wagmi, Viem
- PowchClient and PowchBridgeClient (postMessage protocol only)
No @powch dependencies. Apps that need Powch UI (e.g. Powch PWA) add @powch/react themselves. The app-sdk has no knowledge of Powch internals.
Dev Mode
Local app dev against the cloud (descix serve)
descix serve puts the App Shell, your app and the API on one HTTPS origin, which is
what makes a shell sign-in visible to your app and what makes passkeys work at all. No
platform checkout is required: the shell and /apifront both resolve to the environment your
workspace pins (env.apiUrl), PROD when nothing is pinned. What you see is your app,
standalone — no store chrome.
cd my-app # the repo root is the workspace root
descix config init --env prod # pins env.apiUrl
descix app init -a <app-id> # writes env.app in .descix/workspace.json
descix app set-site -a <app-id> --static site # serve ./site at /p/<app-id>/ (relative to the repo root)
descix serve # the app is detected from the directory API: https://descix.net [workspace env.apiUrl]
Shell: https://descix.net [same origin as API (workspace env.apiUrl)]
Port: 5173 [built-in default (5173)]
App: <app-id> [cwd] mode: standalone
Open: https://localhost:5173/ → App Shell; reads /__descix/app-binding.json and boots <app-id> standalone
App route: https://localhost:5173/p/<app-id> → this app's route on the gateway (the URL the shell loads it from)
/ → https://descix.net (App Shell — sign in here)
/apifront → https://descix.net
/p/<app-id> → ./sitedescix serve starts nothing else — no backend, no app dev server. If your app has its
own dev server you run it yourself and the gateway proxies to it; a --static app needs
nothing running.
Which app, and which port
One serve session serves one app, standalone. It is detected from the directory you are
standing in (the app whose repo root contains your cwd — your repo's env.app, or on a gateway
workspace the env.gateway repo you are inside; longest match wins), or named with --app <id>
from anywhere else. Nothing is written to workspace.json — a persistent
"current app" pointer goes stale and lies. When it cannot name an app it says so and lists
the workspace's apps rather than quietly showing you the store.
The port resolves --port → env.gateway.port → 5173, with strictPort on, and the
resolved value is printed with where it came from. Give a checkout its own port once:
descix config set-gateway-port 5599Standalone is a runtime binding, not a build
The gateway answers GET /__descix/app-binding.json on the shell's own origin with
{mode:'standalone', appId, appUrl, source}. The shell asks its own origin before it mounts,
so the same pre-built cloud shell boots as the store on descix.net and as your app on
localhost, with no rebuild. No binding served (or a timeout, or a malformed one) means the
store — the safe degradation.
There is no __STANDALONE_APP_ID__ define; it is deleted. An app that builds itself
standalone declares it at its own mount: <AppShell appId="my-app" standalone>.
Which environment? The SDK ships pointing at PROD (
https://descix.net).descix config init --env dev|demo|prodpins a workspace — it writesenv.apiUrland both the API and the shell follow it. There is no "local" environment: a local backend is a URL you name — per run withdescix --api-url https://localhost:4000 <command>, or persistently under a custom name withdescix config set-env local --url https://localhost:4000.
Targets, in precedence order:
| Target | 1. flag | 2. workspace.json | 3. derived | 4. default |
|---|---|---|---|---|
| API (/apifront, /api, /mcp, /oauth) | descix --api-url <url> serve, --env dev | env.apiUrl (what config init --env writes) | the env.gateway.shell repo's own microservice.port (local platform checkout) | PROD |
| Shell (/) | descix serve --site-url <url> | env.siteUrl | the API origin when remote, else the env.gateway.shell repo's own site.port | — fails loud |
The default SDK user wins the defaults: whenever the API is a real platform origin, the shell
comes from that same origin, so one origin carries shell + app + /apifront with nothing
configured. Platform developers opt IN to a local shell by naming it
(descix serve --site-url https://localhost:5174, or env.siteUrl) — owning a platform
checkout is not by itself a request to serve it. A localhost target is always something you
named. If the API is local and no shell is configured, the gateway refuses to start rather
than proxying / at the API port. If the configured platform is unreachable, the proxy
surfaces the failure as-is (502/timeout) — nothing is masked or retried against a fallback.
Where the shell gets your app's URL (the product map)
The URL the shell puts in the CodeSite iframe does not come from your app record on the
platform — get_app's ip_site_gcs_path_url is empty for a perfectly working local app. In dev it
comes from a Vite define named __WORKSPACE_PRODUCTS__, built by buildWorkspaceProducts() from
.descix/workspace.json at config-load time.
Every URL in that map is on the gateway origin, absolute, with the port in it —
https://localhost:{gatewayPort}/p/{appId} (the platform app gets the gateway root). That is the
contract, not an implementation detail: the shell reaches into iframe.contentWindow and SplitView
dispatches actions by direct interframe scripting, so an app served from its own dev-server origin
would throw a cross-origin SecurityError and die silently. Your app's own dev-server port is the
origin the gateway proxies to; it is never a URL anything opens or iframes.
Two consequences worth knowing before they cost you an afternoon:
- A Vite
defineis compile-time text substitution. A prebuilt shell bundle has its gateway port baked in. Reuse that bundle behind a second gateway on a different port and the iframe silently loads the original port's app, whilecurling/p/{appId}on the new port correctly returns your bytes. Rebuild the shell when the gateway origin changes. - A running app dev server does not have that problem.
workspaceProductsPluginwatches.descix/workspace.jsonand pushes a fresh map over Vite's WebSocket (descix:workspace-products), sodescix app set-siteshows up without a restart. It is dev-only (apply: 'serve') and auto-injects its own client runtime — no source change in your app.
resolveAppGatewayUrl() is the one resolver that answers "where is this app", shared with
descix app open, and returns the same URL the map contains.
Trust the dev certificate (one time, required for passkey login)
The gateway serves https://localhost with the SDK's self-signed cert (it carries a
subjectAltName for localhost, 127.0.0.1 and ::1), or with your own pair when the workspace
names one. Chrome refuses WebAuthn on a tab whose certificate is untrusted, and it judges the
whole tab — so the Powch sign-in iframe fails even though Powch itself is on a trusted origin.
Symptoms that mean "the dev certificate is not trusted":
- Powch sign-in fails and the Powch/ChatWidget log says only
User cancelled(that is Powch's panel-close rejection; the real error is in the browser). - The browser console says, verbatim:
NotAllowedError: WebAuthn is not supported on sites with TLS certificate errors.
It is the certificate, not your code or your Powch config.
Check, trust, re-check:
descix dev-certs check # exit 0 only when the cert the gateway uses is trusted (--json for machines)
descix dev-certs trust # macOS: asks for your login password, trusts the cert, re-checksAfter descix dev-certs trust succeeds, quit Chrome completely and reopen it — a running
Chrome keeps its old verdict. Then run descix dev-certs check again. descix doctor reports the
same result under "Dev certificate", and the descix serve banner warns
Passkey sign-in will fail: <reason>. Run: descix dev-certs trust whenever the cert is not trusted.
A cert that has expired fails in the browser exactly like an untrusted one.
Linux and Windows: descix dev-certs check reports unverifiable with the reason, and
descix dev-certs trust is macOS-only. Trusting the cert there is a manual step that has not been
verified on DeSciX: import the cert.pem the gateway uses into the store Chrome reads (Linux: the
NSS database at ~/.pki/nssdb, with certutil -d sql:$HOME/.pki/nssdb -A -t "P,," -n descix-localhost -i <cert.pem>;
Windows: the Current User "Trusted Root Certification Authorities" store), then restart the browser.
Prefer your own cert (e.g. from mkcert -install && mkcert localhost 127.0.0.1 ::1)? Point
the workspace at it — no SDK edit, no hand-edited JSON. descix dev-certs check and
descix dev-certs trust then act on that pair:
descix config set-dev-certs --dir ./certs # expects cert.pem + key.pem
descix config set-dev-certs --cert ./certs/x.pem --key ./certs/x-key.pem
descix config set-dev-certs --clear # back to the SDK's shipped pairThat pair has one owner and is used by the gateway and every app dev server behind it —
passkey login is origin-bound, so a cert that reached only the gateway used to work there and
fail on the app. Any cert without a localhost subjectAltName is rejected at startup with the
exact openssl command that mints a correct one.
Configuring the workspace (no hand-editing)
| Key | Verb |
|---|---|
| env.apiUrl | descix config init --env dev\|demo\|prod, or descix config set-env <custom-name> --url <url> (--url is refused on a known name) |
| env.gateway.port | descix config set-gateway-port <n\|none> |
| env.gateway.shell / env.gateway.appRepos[] (gateway workspaces only) | descix config gateway set-shell <repo\|none> / add-repo <repo> / remove-repo <repo> |
| env.devCerts | descix config set-dev-certs --dir\|--cert\|--key\|--clear |
| env.powchUrl | descix config set-powch-url <url\|none> |
| env.siteUrl | descix config set-site-url <url\|none> |
| env.app (THE app this repo holds) | descix app init (from the repo root) / app set-site / app set-port |
Ports are explicit-only — there is no auto-allocation.
The proxy engine is exact-pinned
Vite is a real runtime dependency of this package and is pinned exactly, not caret-ranged.
descix serve asserts the pin before it imports vite and refuses to boot on drift, naming
both the pinned and the resolved version and the package.json that would be loaded. The
declaration in this package's package.json is the single owner; the assert reads it.
Vite config for an app dev server
import fs from 'fs';
import { createViteServerConfig, resolvePowchUrl } from '@descix/app-sdk/dev';
// Where Powch lives has ONE owner. Never hardcode an origin here: a production
// URL baked into a dev build silently points the wallet at production.
const ws = JSON.parse(fs.readFileSync('.descix/workspace.json', 'utf8'));
const powchAppUrl = resolvePowchUrl(ws, { override: process.env.VITE_POWCH_APP_URL });
if (!powchAppUrl) {
throw new Error('Set env.powchUrl in .descix/workspace.json, or VITE_POWCH_APP_URL');
}
export default defineConfig({
server: createViteServerConfig(process.cwd(), { port: 5174 }), // API target resolved as above
define: {
__POWCH_APP_URL__: JSON.stringify(powchAppUrl),
},
});An app declares itself standalone at its <AppShell standalone /> mount, not through
a build-time global.
References
- The App Shell API — what the shell publishes to your app, and what it calls back
- App Shell Core Design
- DeSciX Powch Integration Guide
