The Triangle Ecosystem
A first-day guide for anyone building a product on Polkadot. If you've never touched the Triangle / Host API before, start here β by the end you'll understand why products don't connect to chains themselves, and how to build one that does the right thing.
What is the Triangle?
The shared system that lets small web apps run safely inside a Polkadot wallet.
The Triangle is Parity's architecture for products (small web apps / SPAs) that run inside a host (a Polkadot wallet app). It's called a triangle because of three roles that always show up together:
π§© Product
Your app. An SPA / dApp bundle that runs inside a sandbox. It has no keys, no chain node, no network of its own.
π‘ Host API
The contract between product and host β a versioned, SCALE-encoded message protocol (also called TrUAPI).
ποΈ Host
The wallet app that embeds your product and owns the dangerous bits: identity, signing, chain connections, storage.
A product is not a normal Web2 website. It never asks the user to "connect wallet" or "log in", and it never opens its own RPC connection to a chain. Instead it talks to the host, and the host does those things on its behalf β with the user's keys and permission. This is the single most important idea in this whole document.
The mental model
Five layers. A request flows down; data flows back up.
flowchart TB
P["π§© Product (your SPA)
runs in iframe / webview sandbox"]
PS["π¦ Product SDK
@parity/product-sdk β ergonomic JS toolkit"]
HA["π‘ Host API
SCALE-encoded contract over postMessage"]
HS["ποΈ Host SDK
host-side core (Rust: UserAgentKit, or JS)"]
INFRA["βοΈ Chain Β· π Identity Β· βοΈ Signing Β· πΎ Storage"]
P --> PS --> HA --> HS --> INFRA
INFRA -. "results / subscriptions" .-> HS -.-> HA -.-> PS -.-> P
| Layer | What it is | Who writes it |
|---|
| Layer | What it is | You touch it? |
|---|---|---|
| Product | Your dApp bundle, running sandboxed | β You build this |
| Product SDK | Type-safe JS wrapper over the Host API (@parity/product-sdk) | β You call this |
| Host API | The wire contract (SCALE, ~40 methods, 9 domains) | πΈ Sometimes, directly |
| Host SDK | Host-side implementation (host-sdk / UserAgentKit) | β Host team |
| Host | The actual app: mobile, desktop, or web | β Host team |
The 3 golden rules
If you remember nothing else, remember these.
1 Β· No direct chain
Never open your own WebSocket/RPC to a node. Ask the host for chain reads, subscriptions, and broadcasts. The host owns a pooled, light-client-backed connection.
2 Β· No keys, no login
You never see a seed phrase. You request the account from the host and ask the host to sign. No wallet-connect, no auth screen.
3 Β· Everything via the bridge
All outside access β chain, signing, storage, navigation β flows through the Host API postMessage bridge. The sandbox blocks the rest.
Sandbox & the bridge
Why products can't just fetch() a node β and what they do instead.
The host loads your product into a sandbox β an iframe on web, or an embedded WebView on mobile/desktop. By default that sandbox has:
- β no direct network access
- β no persistent browser storage
- β no access to keys or the blockchain
- β no way to escape to the top-level page
The only channel out is a binary postMessage bridge speaking the Host API. This is a deliberate security boundary: a malicious or buggy product can't leak the user's IP, exfiltrate keys, or sign something the user didn't approve.
flowchart LR
subgraph SB["π Sandbox (iframe / webview)"]
P["π§© Product
+ Product SDK"]
end
subgraph HOST["ποΈ Host application"]
direction TB
BR["Host API handler
(host-container)"]
ACC["π Accounts / signing"]
CH["βοΈ Chain connection
(smoldot light client)"]
ST["πΎ Scoped storage"]
end
P <-->|"SCALE postMessage
(the only way out)"| BR
BR --> ACC
BR --> CH
BR --> ST
CH --> NET(("Polkadot /
People / Bulletin"))
The Host API contract
A versioned, SCALE-encoded message protocol β the "TrUAPI".
The Host API is the wire contract between product and host. It's binary (SCALE-encoded), versioned per-method (v1 codecs), and organised into 10 domains totalling 55 methods in the canonical triangle-js-sdks implementation. The full method reference is below.
Host / General 8
Handshake, features, notifications, navigation, theme, entropy, resource allocation.
Permissions 2
Device + remote consent: allow always / once / never.
Local Storage 3
Scoped key/value persistence per product.
Account Mgmt 7
Host-derived accounts, identity, aliases, ring-VRF proofs, login.
Signing 6
sign_raw, sign_payload, create_transaction (+ legacy variants).
Chain Interaction 13
chain_head_* reads, follow subscription, spec, tx broadcast.
Chat 6
People-chain rooms, bots, messages, custom renders.
Statement Store 4
Ephemeral signed pub/sub messaging.
Preimage 2
Bulletin / IPFS content lookup & submit.
Payments 4
Coinage purse balance, top-up, send, status.
How a message flows
Each call has a requestId and a payload. Three communication patterns exist: request/response, subscription, and reverse subscription (host pushes to product). It always starts with a handshake.
sequenceDiagram
participant P as π§© Product
participant H as ποΈ Host
Note over P,H: 1 Β· Handshake (once)
P->>H: host_handshake_request (random requestId)
H-->>P: host_handshake_response
Note over P,H: 2 Β· Request / response
P->>H: {method}_request (requestId, payload)
H-->>P: {method}_response (same requestId)
Note over P,H: 3 Β· Subscription
P->>H: {method}_start
H-->>P: {method}_receive ...
H-->>P: {method}_receive ...
P->>H: {method}_stop
_request/_response or _start/_receive/_stop, matched by requestId.TrUAPI reference β every interface
All 55 methods, by domain. Source of truth: triangle-js-sdks/packages/host-api/src/protocol (v1 codecs).
This is the complete surface a product can ask the host for. You'll mostly use these through the Product SDK, but here's the raw contract. The host_ prefix = host capabilities, remote_ = chain/network-backed, product_ = host-driven callbacks into the product. Patterns:
_start / _receive / _stop)
rev-sub β reverse subscription (host β product)
host_get_non_product_accounts, host_create_transaction_with_non_product_account). The names below match the canonical code in triangle-js-sdks, which uses legacy instead of non_product.
π Host / General 7
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_handshake | req/res | Negotiate protocol version. u8 β Result<void, HandshakeErr> |
host_feature_supported | req/res | Query feature support. Feature::Chain(genesisHash) β Result<bool> |
host_push_notification | req/res | Schedule/send a push notification. { text, deeplink?, scheduledAt? } β NotificationId |
host_push_notification_cancel | req/res | Cancel a notification. NotificationId β Result<void> |
host_navigate_to | req/res | Ask host to open a URL. str β Result<void, NavigateToErr> |
host_theme_subscribe | sub | Stream host theme. receive: Theme { name, variant: Light|Dark } |
host_derive_entropy | req/res | Derive 32-byte entropy from input. Bytes β Bytes(32) |
host_request_resource_allocation | req/res | Request allowances (statement/bulletin/contract/auto-signing). β Vector<Allocated|Rejected|NotAvailable> |
π Permissions 2
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_device_permission | req/res | Request a device permission (Camera, Mic, Bluetooth, NFC, Location, Clipboard, Biometricsβ¦). β bool |
remote_permission | req/res | Request a remote-op permission: Remote(methods), WebRtc, ChainSubmit, PreimageSubmit, StatementSubmit. β bool |
πΎ Local Storage 3
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_local_storage_read | req/res | Read scoped value. key β Option<Bytes> |
host_local_storage_write | req/res | Write scoped value. (key, value) β Result<void> |
host_local_storage_clear | req/res | Clear a scoped key. key β Result<void> |
π€ Account Management & Identity 7
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_account_connection_status_subscribe | sub | Auth/account connection status. receive: connected | disconnected |
host_account_get | req/res | Get product-derived account. ProductAccountId(dotNs, index) β { publicKey } |
host_account_get_alias | req/res | Get contextual ring-VRF alias. ProductAccountId β { context, alias } |
host_account_create_proof | req/res | Create a ring-VRF proof. (accountId, ringLocation, msg) β RingVrfProof |
host_get_legacy_accounts | req/res | List legacy/non-product accounts. β Vector<{ publicKey, name }> |
host_get_user_id | req/res | Get the user's primary DotNS identity. β { primaryUsername } |
host_request_login | req/res | Connect/login the user. Option<str> β success | alreadyConnected | rejected |
βοΈ Signing & Transactions 6
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_create_transaction | req/res | Sign a tx with a product account; returns full signed extrinsic. { signer, genesisHash, callData, extensions, txExtVersion } β Bytes |
host_create_transaction_with_legacy_account | req/res | Same, with a legacy account signer. β Bytes |
host_sign_raw | req/res | Sign raw bytes with a product account. { account, payload } β SigningResult |
host_sign_payload | req/res | Sign a Substrate tx payload (product account). { account, payload{β¦} } β SigningResult |
host_sign_raw_with_legacy_account | req/res | Sign raw bytes with a legacy signer. β { signature, signedTransaction? } |
host_sign_payload_with_legacy_account | req/res | Sign a tx payload with a legacy signer. β SigningResult |
βοΈ Chain Interaction 13
| Method | Pattern | Purpose & I/O |
|---|---|---|
remote_chain_head_follow_subscribe | sub | Follow chain-head events. start: { genesisHash, withRuntime }; receive: ChainHeadEvent |
remote_chain_head_header | req/res | Fetch a block header. { genesisHash, followSubscriptionId, hash } β Hex? |
remote_chain_head_body | req/res | Start a block-body operation. β Started{operationId} | LimitReached |
remote_chain_head_storage | req/res | Start a storage query. { β¦, items[], childTrie? } β OperationStarted |
remote_chain_head_call | req/res | Start a runtime call. { β¦, function, callParameters } β OperationStarted |
remote_chain_head_unpin | req/res | Unpin block hashes. { β¦, hashes[] } β Result<void> |
remote_chain_head_continue | req/res | Continue a paged operation. { β¦, operationId } β Result<void> |
remote_chain_head_stop_operation | req/res | Stop a chain-head operation. { β¦, operationId } β Result<void> |
remote_chain_spec_genesis_hash | req/res | Get genesis hash. genesisHash β Hex |
remote_chain_spec_chain_name | req/res | Get chain name. β str |
remote_chain_spec_properties | req/res | Get chain properties. β str |
remote_chain_transaction_broadcast | req/res | Broadcast a signed tx. { genesisHash, transaction } β str? |
remote_chain_transaction_stop | req/res | Stop a broadcast/operation. { genesisHash, operationId } β Result<void> |
π¬ Chat 6
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_chat_create_room | req/res | Register/create a room. { roomId, name, icon } β New | Exists |
host_chat_register_bot | req/res | Register a bot. { botId, name, icon } β New | Exists |
host_chat_list_subscribe | sub | Stream the product's rooms. receive: Vector<{ roomId, participatingAs }> |
host_chat_post_message | req/res | Post a message (Text, RichText, Actions, File, Reaction, Custom). β { messageId } |
host_chat_action_subscribe | sub | Stream chat events. receive: { roomId, peer, payload: MessagePosted|ActionTriggered|Command } |
product_chat_custom_message_render_subscribe | rev-sub | Host asks product to render a custom message. start: { messageId, messageType, payload }; receive: CustomRendererNode |
π£ Statement Store 4
| Method | Pattern | Purpose & I/O |
|---|---|---|
remote_statement_store_subscribe | sub | Subscribe to statements by topic filter. start: MatchAll|MatchAny(topics); receive: { statements[], isComplete } |
remote_statement_store_create_proof | req/res | Create a statement proof (product account). (accountId, statement) β StatementProof |
remote_statement_store_create_proof_authorized | req/res | Create a proof using a host-authorized account. statement β StatementProof |
remote_statement_store_submit | req/res | Submit a signed statement. SignedStatement{ proof, topics, data?, expiry?, channel? } β Result<void> |
ποΈ Preimage 2
| Method | Pattern | Purpose & I/O |
|---|---|---|
remote_preimage_lookup_subscribe | sub | Subscribe to preimage availability. start: PreimageKey; receive: PreimageValue? |
remote_preimage_submit | req/res | Submit a preimage, get its key. PreimageValue β PreimageKey |
π° Payments (Coinage) 4
| Method | Pattern | Purpose & I/O |
|---|---|---|
host_payment_balance_subscribe | sub | Stream a purse balance. start: { purse? }; receive: { available: u128 } |
host_payment_top_up | req/res | Top up a purse. { into?, amount, source } β Result<void> |
host_payment_request | req/res | Send a payment. { from?, amount, destination } β { id } |
host_payment_status_subscribe | sub | Stream payment status. start: PaymentId; receive: Processing | Completed | Failed(msg) |
product_chat_custom_message_render_subscribe flips the direction: the host drives, asking the product to render custom UI on demand.
Chain access flow
You use a normal PAPI client β it's just wired to the host underneath.
Here's the magic that keeps rule #1 painless: the Product SDK gives you a standard PAPI (polkadot-api) client backed by a custom JsonRpcProvider. That provider doesn't talk to a node β it translates every JSON-RPC chainHead_* call into a Host API method and sends it over the bridge.
sequenceDiagram
participant App as π§© Your code
participant PAPI as PAPI client
participant Prov as createPapiProvider()
participant Host as ποΈ Host
participant Chain as βοΈ Chain
App->>PAPI: query balance / subscribe to head
PAPI->>Prov: chainHead_v1_storage(...)
Prov->>Host: remote_chain_head_storage (over Host API)
Host->>Chain: light-client / pooled RPC
Chain-->>Host: storage value
Host-->>Prov: response
Prov-->>PAPI: JSON-RPC result
PAPI-->>App: typed value
Chain methods the host exposes include remote_chain_head_storage, remote_chain_head_call, remote_chain_head_follow_subscribe, remote_chain_spec_genesis_hash, and remote_chain_transaction_broadcast. You rarely call these directly β the PAPI provider does it for you.
upload() / readFile(cid) reuse the signing + broadcast flow below.
Signing a transaction
You build the call; the host holds the key and asks the user.
You never hold a private key. To submit a transaction you build the call with PAPI, hand it a signer that delegates to the host, and the host shows the user an approval prompt and returns a signed extrinsic.
sequenceDiagram
participant App as π§© Your code
participant Signer as Product SDK signer
participant Host as ποΈ Host
participant User as π€ User
participant Chain as βοΈ Chain
App->>App: build tx with PAPI
App->>Signer: signSubmitAndWatch(signer)
Signer->>Host: host_create_transaction (account + payload)
Host->>User: approve this transaction?
User-->>Host: β
approve
Host-->>Signer: signed extrinsic
Signer->>Host: remote_chain_transaction_broadcast
Host->>Chain: broadcast
Chain-->>App: in block β finalized
host_create_transaction (host returns the full signed extrinsic). Older flows used host_sign_payload / host_sign_raw.The signer needs the ChainSubmit permission, which the SDK requests on connect. Without it the host rejects signing with PermissionDenied.
Product SDK vs. raw Triangle
Two SDK layers, two npm scopes. Here's which to reach for.
This is the part that confuses everyone, so let's be precise. There are two SDK layers, both published to npm, one stacked on the other:
π£ @parity/product-sdk
high-level what you usually want
Ergonomic, batteries-included toolkit. createApp() gives you wallet, storage, typed chain client, transactions, contracts, Bulletin, statement store β all host-aware. Built on top of the Triangle layer below.
π΅ @novasamatech/* (Triangle)
low-level the raw contract
The Triangle protocol itself (repo: triangle-js-sdks). @novasamatech/host-api is the wire protocol; @novasamatech/host-api-wrapper (formerly @novasamatech/product-sdk) is the thin product-side wrapper.
flowchart TB
A["π£ @parity/product-sdk-*
chain-client Β· tx Β· signer Β· contracts Β· bulletin Β· statement-store"]
B["π΅ @novasamatech/host-api-wrapper
thin ergonomic wrapper (was @novasamatech/product-sdk)"]
C["π΅ @novasamatech/host-api
SCALE protocol Β· provider Β· transport"]
D["ποΈ Host (mobile / desktop / web)"]
A -->|peer dep| B --> C -->|postMessage| D
@parity/product-sdk declares the @novasamatech packages as peer deps and routes through them to reach the host."But some things still need raw Triangle"
True. @parity/product-sdk covers the common cases cleanly, but the moment you need a capability it doesn't wrap yet β a brand-new Host API method, a host extension (window.host.ext.*), or fine-grained protocol control β you drop down to @novasamatech/host-api / host-api-wrapper directly. The @parity/product-sdk-host package centralizes that access so the rest of your code doesn't depend on Novasama packages directly.
@parity/product-sdk. Only reach into @novasamatech/host-api* when you hit something the high-level SDK doesn't expose yet.
Host vs. standalone (dev) mode
The SDK auto-detects whether it's running inside a host:
- Inside a host β real accounts, real signing prompts, host-routed chain. This is production.
- Standalone / dev β
connect("dev")gives deterministic Alice/Bob dev accounts for local testing. No host needed.
Package map
What each @parity/product-sdk-* package does.
| Package | Use it for |
|---|---|
product-sdk | Umbrella β re-exports everything; createApp() entry point |
chain-client | Typed multi-chain PAPI client (host-routed) |
descriptors | PAPI-generated chain descriptors (types) |
tx | Submit + watch transactions, batching, dry-run |
signer | SignerManager: account discovery & signing |
contracts | Typed smart-contract calls (PVM/Solidity, cdm.json) |
bulletin | Upload / fetch content on Bulletin Chain |
statement-store | Ephemeral pub/sub messaging |
keys | App/session keys derived from a signature (no seed) |
storage | Key/value storage with host/browser detection |
host | Detect & talk to the host container; isolates Novasama deps |
address | SS58 β H160 encoding, validation, conversion |
crypto / utils / logger | Encryption, encoding/formatting, structured logs |
Your first product
The smallest thing that works.
One call sets up wallet, storage, chain and Bulletin. Notice: no RPC URL, no wallet-connect modal, no key handling.
import { createApp } from '@parity/product-sdk';
const app = await createApp({
name: 'my-app',
logLevel: 'info',
});
// 1 Β· Get the account from the host (no "connect wallet" UI)
const { accounts } = await app.wallet.connect();
// 2 Β· Scoped storage, no setup
await app.storage.set('key', 'value');
// 3 Β· Bulletin storage (decentralized) β may be null if disabled
if (app.bulletin) {
const cid = await app.bulletin.upload('hello world');
}
From here you'd add a typed chain query, or build a transaction and submit it with the host-backed signer (the flow in Signing a transaction). The product-sdk-app-builder skill scaffolds a full project and picks the right packages for you.
The hosts
The apps that embed the host-sdk and run your product. Same Host API, three surfaces.
All hosts share the same host-sdk core (public name UserAgentKit, a cross-platform Rust SDK) and implement the same Host API β so a product that works in one should work in all three.
π± Mobile Host
private Swift Β· Kotlin
Native iOS + Android wallet. Internally "Project Bravo"; repo renamed to truhost.
π₯οΈ Polkadot Desktop
private Electron Β· React
Desktop host for browsing Polkadot products, resolved via dotNS. Most complete Host API support today.
π dotli (web)
public TypeScript Β· Bun
Permissionless in-browser host: resolves .dot apps client-side with smoldot, renders them in a sandboxed iframe. Reference implementation.
How the web host (dotli) resolves an app
- Host reads the
.dotsubdomain from the URL. smoldotlight client resolves the dotNS name on Asset Hub (racing an HTTP gateway for speed).- Content is fetched P2P from Bulletin / IPFS, with a gateway fallback.
- A Service Worker serves the app bundle into a sandboxed iframe.
- The SPA talks back only through the host-container
postMessagebridge.
host-sdk is the Rust implementation; triangle-js-sdks is a wire-compatible TypeScript implementation. Both speak the same Host API, so your product doesn't care which one a host uses.
Not the same thing: the Nova apps
The production polkadot-app-ios-v2 / polkadot-app-android-v2 (Nova) are currently-shipped Polkadot wallets, but they use a different, older architecture β they are not host-sdk / Triangle hosts. Use them as UX references only, not as an architecture template. Building the new Triangle mobile host is exactly why Project Bravo exists.
Example products & tools
Real things to read, run, and copy from.
bravo-smoke / host-diag
private Nuxt 3 Β· Vue
The reference "smoke test" product. Validates the full host contract in isolation: handshake, account access, signing, storage, chain, statement store, deep links. Best place to see every capability exercised once. Live: hostdiag91.paseo.li
host-playground
private Next.js
Interactive playground to test the SDK inside a host webview. Try Host API calls hands-on. Live: host-playground.dot.li
RevX
private Nuxt 4
Browser-based IDE to write, compile and deploy Polkadot smart contracts. paritytech/revx β
Host API Explorer
public tool
Browse every Host API method, domain and type in one place. Open the explorer β
Glossary
The words people throw around on day one.
| Triangle | The product β Host API β host architecture as a whole. |
| Host | The wallet app running your product (mobile / desktop / web). |
| Product / SPA / dApp | Your sandboxed app running inside a host. |
| Host API / TrUAPI | The SCALE-encoded contract between product and host. |
| host-sdk / UserAgentKit | The shared Rust core every host embeds. |
| Product SDK | JS toolkit you build with. @parity/product-sdk (high-level) on top of @novasamatech/host-api* (low-level Triangle). |
| ProductView | The host's sandboxed container that loads a product. |
| PAPI | polkadot-api β the typed chain client, wired to the host here. |
| dotNS | Decentralized naming (.dot / .li) that resolves to product content. |
| Bulletin Chain | Decentralized storage chain; treated as "just another chain". |
| PAPP | Polkadot Mobile, paired to a host via deeplink for signing. |
| Statement Store | Ephemeral signed pub/sub messaging layer. |
All repositories & links
Where the code lives. Some repos are private β that's expected.
| Repo / link | What | Access |
|---|---|---|
| triangle-js-sdks | Triangle protocol: @novasamatech/host-api* (TS) | private |
| product-sdk | High-level @parity/product-sdk* | private |
| useragent-kit (host-sdk) | Shared Rust host core | private |
| truhost (mobile-host) | iOS + Android host (Project Bravo) | private |
| polkadot-desktop | Desktop host | private |
| dotli-community | Web host (reference) | public |
| host-playground | Host API playground | private |
| host-api-explorer | Host API browser | public |
| Host API Explorer (live) | Browse methods online | public |
| host-playground.dot.li | Live playground | public |