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.

Audience: new product builders Level: overview + light code Read time: ~15 min

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 core rule (from Karim, product lead)
Do not add "connect wallet" or "log in" buttons inside a Triangle product. The host already knows who the user is and provides the account. Your job is just to ask for it.

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
Product β†’ Product SDK β†’ Host API β†’ Host SDK β†’ infrastructure. You write the top two layers; the host owns the rest.
LayerWhat it isWho writes it
LayerWhat it isYou touch it?
ProductYour dApp bundle, running sandboxedβœ… You build this
Product SDKType-safe JS wrapper over the Host API (@parity/product-sdk)βœ… You call this
Host APIThe wire contract (SCALE, ~40 methods, 9 domains)πŸ”Έ Sometimes, directly
Host SDKHost-side implementation (host-sdk / UserAgentKit)❌ Host team
HostThe 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 product is boxed in. The host mediates every interaction with the outside world.
πŸ’‘ Why this is actually nice for you
You don't ship RPC endpoints, manage chain specs, build a wallet-connect modal, or handle key storage. The host already solved all of that. You write app logic and ask the host for capabilities.

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
        
Every method becomes framed actions like _request/_response or _start/_receive/_stop, matched by requestId.
πŸ”Ž Explore it live
Browse every method in the Host API Explorer, try calls hands-on in the Host Playground, or read the full reference below.

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:

req/res β€” request β†’ response sub β€” subscription (_start / _receive / _stop) rev-sub β€” reverse subscription (host β†’ product)
πŸ“ Naming note
The public Host API Explorer documents v0.1/v0.2 and uses a few different names (e.g. 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

MethodPatternPurpose & I/O
host_handshakereq/resNegotiate protocol version. u8 β†’ Result<void, HandshakeErr>
host_feature_supportedreq/resQuery feature support. Feature::Chain(genesisHash) β†’ Result<bool>
host_push_notificationreq/resSchedule/send a push notification. { text, deeplink?, scheduledAt? } β†’ NotificationId
host_push_notification_cancelreq/resCancel a notification. NotificationId β†’ Result<void>
host_navigate_toreq/resAsk host to open a URL. str β†’ Result<void, NavigateToErr>
host_theme_subscribesubStream host theme. receive: Theme { name, variant: Light|Dark }
host_derive_entropyreq/resDerive 32-byte entropy from input. Bytes β†’ Bytes(32)
host_request_resource_allocationreq/resRequest allowances (statement/bulletin/contract/auto-signing). β†’ Vector<Allocated|Rejected|NotAvailable>

πŸ” Permissions 2

MethodPatternPurpose & I/O
host_device_permissionreq/resRequest a device permission (Camera, Mic, Bluetooth, NFC, Location, Clipboard, Biometrics…). β†’ bool
remote_permissionreq/resRequest a remote-op permission: Remote(methods), WebRtc, ChainSubmit, PreimageSubmit, StatementSubmit. β†’ bool

πŸ’Ύ Local Storage 3

MethodPatternPurpose & I/O
host_local_storage_readreq/resRead scoped value. key β†’ Option<Bytes>
host_local_storage_writereq/resWrite scoped value. (key, value) β†’ Result<void>
host_local_storage_clearreq/resClear a scoped key. key β†’ Result<void>

πŸ‘€ Account Management & Identity 7

MethodPatternPurpose & I/O
host_account_connection_status_subscribesubAuth/account connection status. receive: connected | disconnected
host_account_getreq/resGet product-derived account. ProductAccountId(dotNs, index) β†’ { publicKey }
host_account_get_aliasreq/resGet contextual ring-VRF alias. ProductAccountId β†’ { context, alias }
host_account_create_proofreq/resCreate a ring-VRF proof. (accountId, ringLocation, msg) β†’ RingVrfProof
host_get_legacy_accountsreq/resList legacy/non-product accounts. β†’ Vector<{ publicKey, name }>
host_get_user_idreq/resGet the user's primary DotNS identity. β†’ { primaryUsername }
host_request_loginreq/resConnect/login the user. Option<str> β†’ success | alreadyConnected | rejected

✍️ Signing & Transactions 6

MethodPatternPurpose & I/O
host_create_transactionreq/resSign a tx with a product account; returns full signed extrinsic. { signer, genesisHash, callData, extensions, txExtVersion } β†’ Bytes
host_create_transaction_with_legacy_accountreq/resSame, with a legacy account signer. β†’ Bytes
host_sign_rawreq/resSign raw bytes with a product account. { account, payload } β†’ SigningResult
host_sign_payloadreq/resSign a Substrate tx payload (product account). { account, payload{…} } β†’ SigningResult
host_sign_raw_with_legacy_accountreq/resSign raw bytes with a legacy signer. β†’ { signature, signedTransaction? }
host_sign_payload_with_legacy_accountreq/resSign a tx payload with a legacy signer. β†’ SigningResult

⛓️ Chain Interaction 13

MethodPatternPurpose & I/O
remote_chain_head_follow_subscribesubFollow chain-head events. start: { genesisHash, withRuntime }; receive: ChainHeadEvent
remote_chain_head_headerreq/resFetch a block header. { genesisHash, followSubscriptionId, hash } β†’ Hex?
remote_chain_head_bodyreq/resStart a block-body operation. β†’ Started{operationId} | LimitReached
remote_chain_head_storagereq/resStart a storage query. { …, items[], childTrie? } β†’ OperationStarted
remote_chain_head_callreq/resStart a runtime call. { …, function, callParameters } β†’ OperationStarted
remote_chain_head_unpinreq/resUnpin block hashes. { …, hashes[] } β†’ Result<void>
remote_chain_head_continuereq/resContinue a paged operation. { …, operationId } β†’ Result<void>
remote_chain_head_stop_operationreq/resStop a chain-head operation. { …, operationId } β†’ Result<void>
remote_chain_spec_genesis_hashreq/resGet genesis hash. genesisHash β†’ Hex
remote_chain_spec_chain_namereq/resGet chain name. β†’ str
remote_chain_spec_propertiesreq/resGet chain properties. β†’ str
remote_chain_transaction_broadcastreq/resBroadcast a signed tx. { genesisHash, transaction } β†’ str?
remote_chain_transaction_stopreq/resStop a broadcast/operation. { genesisHash, operationId } β†’ Result<void>

πŸ’¬ Chat 6

MethodPatternPurpose & I/O
host_chat_create_roomreq/resRegister/create a room. { roomId, name, icon } β†’ New | Exists
host_chat_register_botreq/resRegister a bot. { botId, name, icon } β†’ New | Exists
host_chat_list_subscribesubStream the product's rooms. receive: Vector<{ roomId, participatingAs }>
host_chat_post_messagereq/resPost a message (Text, RichText, Actions, File, Reaction, Custom). β†’ { messageId }
host_chat_action_subscribesubStream chat events. receive: { roomId, peer, payload: MessagePosted|ActionTriggered|Command }
product_chat_custom_message_render_subscriberev-subHost asks product to render a custom message. start: { messageId, messageType, payload }; receive: CustomRendererNode

πŸ“£ Statement Store 4

MethodPatternPurpose & I/O
remote_statement_store_subscribesubSubscribe to statements by topic filter. start: MatchAll|MatchAny(topics); receive: { statements[], isComplete }
remote_statement_store_create_proofreq/resCreate a statement proof (product account). (accountId, statement) β†’ StatementProof
remote_statement_store_create_proof_authorizedreq/resCreate a proof using a host-authorized account. statement β†’ StatementProof
remote_statement_store_submitreq/resSubmit a signed statement. SignedStatement{ proof, topics, data?, expiry?, channel? } β†’ Result<void>

πŸ—‚οΈ Preimage 2

MethodPatternPurpose & I/O
remote_preimage_lookup_subscribesubSubscribe to preimage availability. start: PreimageKey; receive: PreimageValue?
remote_preimage_submitreq/resSubmit a preimage, get its key. PreimageValue β†’ PreimageKey

πŸ’° Payments (Coinage) 4

MethodPatternPurpose & I/O
host_payment_balance_subscribesubStream a purse balance. start: { purse? }; receive: { available: u128 }
host_payment_top_upreq/resTop up a purse. { into?, amount, source } β†’ Result<void>
host_payment_requestreq/resSend a payment. { from?, amount, destination } β†’ { id }
host_payment_status_subscribesubStream payment status. start: PaymentId; receive: Processing | Completed | Failed(msg)
πŸ” Reverse subscriptions
Most methods are product β†’ host. A reverse subscription like 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
        
From your code it looks like a normal PAPI client. Underneath, every read/subscribe/broadcast is a Host API call.

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.

βœ… Bulletin is "just another chain"
Gav's direction: the Bulletin Chain (decentralized storage) is treated like any other chain. The Product SDK runs its transactions through the same Host API path β€” so 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
        
The newer flow uses 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.

🧭 Rule of thumb
Start with @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.

PackageUse it for
product-sdkUmbrella β€” re-exports everything; createApp() entry point
chain-clientTyped multi-chain PAPI client (host-routed)
descriptorsPAPI-generated chain descriptors (types)
txSubmit + watch transactions, batching, dry-run
signerSignerManager: account discovery & signing
contractsTyped smart-contract calls (PVM/Solidity, cdm.json)
bulletinUpload / fetch content on Bulletin Chain
statement-storeEphemeral pub/sub messaging
keysApp/session keys derived from a signature (no seed)
storageKey/value storage with host/browser detection
hostDetect & talk to the host container; isolates Novasama deps
addressSS58 ⇄ H160 encoding, validation, conversion
crypto / utils / loggerEncryption, 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

  1. Host reads the .dot subdomain from the URL.
  2. smoldot light client resolves the dotNS name on Asset Hub (racing an HTTP gateway for speed).
  3. Content is fetched P2P from Bulletin / IPFS, with a gateway fallback.
  4. A Service Worker serves the app bundle into a sandboxed iframe.
  5. The SPA talks back only through the host-container postMessage bridge.
↔️ Two implementations, one protocol
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.

TriangleThe product ↔ Host API ↔ host architecture as a whole.
HostThe wallet app running your product (mobile / desktop / web).
Product / SPA / dAppYour sandboxed app running inside a host.
Host API / TrUAPIThe SCALE-encoded contract between product and host.
host-sdk / UserAgentKitThe shared Rust core every host embeds.
Product SDKJS toolkit you build with. @parity/product-sdk (high-level) on top of @novasamatech/host-api* (low-level Triangle).
ProductViewThe host's sandboxed container that loads a product.
PAPIpolkadot-api β€” the typed chain client, wired to the host here.
dotNSDecentralized naming (.dot / .li) that resolves to product content.
Bulletin ChainDecentralized storage chain; treated as "just another chain".
PAPPPolkadot Mobile, paired to a host via deeplink for signing.
Statement StoreEphemeral signed pub/sub messaging layer.

All repositories & links

Where the code lives. Some repos are private β€” that's expected.

Repo / linkWhatAccess
triangle-js-sdksTriangle protocol: @novasamatech/host-api* (TS)private
product-sdkHigh-level @parity/product-sdk*private
useragent-kit (host-sdk)Shared Rust host coreprivate
truhost (mobile-host)iOS + Android host (Project Bravo)private
polkadot-desktopDesktop hostprivate
dotli-communityWeb host (reference)public
host-playgroundHost API playgroundprivate
host-api-explorerHost API browserpublic
Host API Explorer (live)Browse methods onlinepublic
host-playground.dot.liLive playgroundpublic