SDK
Every Smart Engines cluster speaks plain HTTP. You could hand-roll fetch calls against the endpoints documented in the API Reference — but you’d be re-implementing cluster discovery, Web3 challenge-response signing, retry logic, and request validation for every project. The SDK does all of that once, correctly, so you can spend your time building.
The package is @hsuite/smart-engines-sdk. It is published to npm and ships its own TypeScript types.
Page index
Section titled “Page index”The 30-second version
Section titled “The 30-second version”-
Install the SDK.
Terminal window npm install @hsuite/smart-engines-sdk -
Connect to a cluster and start building.
import { BaasClient } from '@hsuite/smart-engines-sdk';// Zero-config: the SDK picks a cluster on the network for you.const baas = await BaasClient.connectToCluster({network: 'testnet',appId: 'app_abc',});await baas.db.insert('users', { name: 'Alice' });
That’s it. No gateway URL to memorise, no validator IP to track. network: 'testnet' resolves the network’s entrypoint, fetches the cluster list, and picks one at random.
How the pieces fit
Section titled “How the pieces fit”The SDK ships two public top-level clients, one per publicly-reachable tier (the validator-direct SmartEngineClient raw tier was retired in SDK 8.0 — see the callout below):
| Client | Talks to | Use it for |
|---|---|---|
BaasClient |
Host / BaaS tier (via the gateway) | Database, storage, functions, messaging, agents, entities, rules, deployment — the canonical way to reach web3 through a cluster |
SmartGatewayClient |
Gateway tier | Routing, custom domains, DNS |
Plus the standalone gateway-fronted clients you point at a cluster with createHttpClient — SubscriptionClient, FaucetClient, ResourcesClient, PersonhoodClient.
flowchart LR
App["Your smart-app"] -->|"@hsuite/smart-engines-sdk"| SDK
SDK -->|"connectToCluster({network})"| Disc["Cluster discovery"]
Disc -->|"random healthy cluster"| GW["Cluster gateway"]
GW --> V["Validator tier"]
GW --> H["Host / BaaS tier"]
BaaS client surface
Section titled “BaaS client surface”BaasClient exposes the host tier as typed sub-clients — db, records, storage, functions, messaging, deployment, agents, customerSession, rules, entities, operations and receipts. Every method takes an optional trailing options bag.
forCustomer(token) — act for one end user (start here)
Section titled “forCustomer(token) — act for one end user (start here)”The motivating case is a multi-tenant app: your app holds its own Bearer auth on the client, but each downstream call acts on behalf of a specific end user. That end user’s session token is not a metering label — on the host it is the authorization scope: the platform binds every write to that customer and answers 404 for any other customer’s object (db, storage and messaging).
forCustomer binds the token once and returns the customer-reachable sub-clients over a transport that carries it on every call:
const asAlice = baas.forCustomer(request.customerToken);
await asAlice.db.insert('orders', { item: 'widget' }); // bound to Aliceawait asAlice.db.find('orders'); // returns only Alice'sawait asAlice.storage.listFiles(); // only Alice's filesawait asAlice.messaging.getHistory('alice-channel'); // only her channelsawait asAlice.records.list('compose-drafts'); // only her durable drafts (see `baas.records` below)Inside a SmartEngineModule request the SDK forwards the served customer for you. SmartEngineModule already knows which customer a request is served to (the bearer CustomerSessionGuard validated), so a data-plane call made on the app-level client with no customerToken / onBehalfOf of its own — baas.db.find('orders') in a handler — is sent with that customer’s token anyway, on db, storage, messaging, functions, entities, agents, records and receipts calls, the bare listings baas.agents.list() and baas.entities.listByOwner() included. Boot code, cron jobs and @AppOwnerOnly() / @Public() routes have no customer in context and stay the app acting as itself. An explicit per-call or forCustomer customer always wins. For the rare read the app genuinely makes as itself while serving a customer (a config collection every customer shares), say so per call: baas.db.find('config', {}, undefined, { asApp: true }).
Every app is strict unless it explicitly opted out (since SDK 7.0): the host refuses any customer-reachable data-plane call that carries no customer identity with a 403, so an app that forgets to forward its end user’s bearer on one route can never silently run it with tenant-wide authority. Tenant-administration routes (collection drop, transitions, usage stats) are app-only by definition and stay reachable. Flip the posture per app only when the app genuinely acts as itself everywhere:
hsuite customer-scope # print the app's effective policy, and whether its config carries it or the default applieshsuite customer-scope permissive # the earlier semantics — app acts tenant-widehsuite customer-scope strict # (the default) refuse identity-less data-plane callshsuite deploy reads the same policy just before it deploys and prints a warning when it is permissive; the warning never blocks the deploy.
Apps created before this default are not grandfathered: a stored config with no customerScope field resolves strict too. An app that forwards no customer identity cannot be told apart from the app acting as itself, so under permissive every one of its customers shared one unbound pool — A wrote, B listed, overwrote and deleted. If your app genuinely acts as itself everywhere, opt out explicitly with hsuite customer-scope permissive; otherwise forward the end user’s session (forCustomer, or a per-call customerToken) on every /baas/{db,storage,messaging,entities,agents} call.
Per-call customerToken (the same scope, one call at a time)
Section titled “Per-call customerToken (the same scope, one call at a time)”The same token can ride a single call when a scoped view would be awkward:
await baas.db.insert('orders', { item: 'widget' }, { customerToken: endUserSessionToken });await baas.functions.invoke('fn-1', { orderId: '123' }, { customerToken: endUserSessionToken });The options bag is { customerToken?: string; onBehalfOf?: string; customerClaim?: string; sponsorCustomerWrite?: boolean; asApp?: boolean; headers?: Record<string, string> } — headers is a per-call escape hatch merged last. Every method of every sub-client takes it as its trailing argument, so there is no call that cannot carry the customer. A per-call value wins over one bound by forCustomer.
The three identity options attach to different headers and cover different routes:
| Option | Header(s) sent | Use it on |
|---|---|---|
customerToken |
X-Customer-Session-Token (app stays the Authorization bearer) |
db / storage / messaging — the customer authorization scope |
onBehalfOf |
Authorization: Bearer <jwt> (replaces the app token) and X-Customer-Session-Claim |
customer-paid owner-acting routes — entity value-movement, agents.fund / withdraw / execute, token / account / topic prepare/execute-create |
customerClaim |
X-Customer-Session-Claim only (app stays the Authorization bearer) |
platform-paid single-shot creates and every agent create — see below |
sponsorCustomerWrite |
X-Platform-Paid-Sponsor: app |
a platform-paid create that carries a customer: the app states it pays for them — see below |
onBehalfOf — act as an owner (agents + entities)
Section titled “onBehalfOf — act as an owner (agents + entities)”When an app-as-proxy must call an owner-scoped route (an agent action, or an entity value-movement) for one of its end users, pass onBehalfOf: <customer session JWT>. The SDK forwards that JWT as both Authorization: Bearer and X-Customer-Session-Claim, so the host authorises the call as the owner. It threads through agents.fund / trade / withdraw / execute and every entities.* value-movement method, and works on a forCustomer view too (a per-call option wins over the bound token).
await baas.agents.execute('agent-1', { capability: 'payout', chain: 'xrpl', params }, { onBehalfOf: customerJwt });await baas.entities.transfer('entity-1', { chain: 'xrpl', to: 'rDest...', amount: '5' }, { onBehalfOf: customerJwt });customerClaim — vouch for a customer on a platform-paid or agent create
Section titled “customerClaim — vouch for a customer on a platform-paid or agent create”The single-shot creators — entities.createToken / createAccount / createTopic / createAgent and agents.register — are platform-paid: the cluster pays the fee and threshold-signs, so the host admits only the app as the principal. Sending onBehalfOf there would make the customer the bearer and be refused with 403. To still record which customer the app is creating for, pass customerClaim: <customer session JWT>. The app stays the Authorization bearer; the customer’s Mode-1 JWT rides as X-Customer-Session-Claim only, and the host binds it as the entity owner for an agent (a token / account / topic stays app-owned). The agent two-phase create (entities.prepareCreateAgent / executeCreateAgent) takes the owner the same way: the claim is the only thing that makes the agent the customer’s, not the app’s.
const prep = await baas.entities.prepareCreateAgent( { primaryChain: 'xrpl', name: 'Payout Bot', ruleRef }, // this customer becomes the agent's owner — and the app says it pays for them { customerClaim: customerJwt, sponsorCustomerWrite: true },);A forCustomer(token) view does this for you: it sends the bound token as customerClaim automatically on those platform-paid create routes and on the agent two-phase create, and as the ordinary customerToken scope everywhere else — you don’t pass customerClaim by hand unless you’re on a bare per-call client. The view vouches on agents.register and the agent two-phase create from SDK 8.1; up to 8.0 it vouches only on the five entities single-shot creates, so pass customerClaim on those agent calls yourself. When both onBehalfOf and customerClaim are given, onBehalfOf wins. An agent the app creates on its own client, with no claim, is the app’s: the host shows it to the app acting as itself and to no customer.
baas.records — durable per-customer records (SDK 8.1)
Section titled “baas.records — durable per-customer records (SDK 8.1)”baas.records keeps small JSON records for your app on the host, with the author enforced by the host rather than by your code. Use it instead of a process Map or a file in /tmp for anything one user must not read from another: that kind of store is per pod, so with several clusters every pod holds its own view, and an author check in app code holds only until some new reader of the map forgets it. It is exposed on the app-level client and on every forCustomer(token) view.
Two namespaces:
| Namespace | Who may use it | Scope |
|---|---|---|
compose-drafts |
a verified customer, through forCustomer(token) |
the customer’s own drafts. Another customer’s id answers 404, exactly what an unknown id does — no existence oracle. A call with no customer identity is 403. |
template-bootstrap |
the app acting as itself | app-level bootstrap data. A customer, forwarded or direct, is 403. |
const drafts = baas.forCustomer(request.customerToken).records;
// version 0 creates exactly once; the mutationId makes a retry safeawait drafts.compareAndSet('compose-drafts', 'draft-1', { expectedVersion: 0, mutationId: 'create-draft-1', data: { name: 'Payout Bot', systemPrompt },});
const mine = await drafts.list('compose-drafts'); // only this customer's draftsconst one = await drafts.get('compose-drafts', 'draft-1'); // 404 for anyone else'sawait drafts.compareAndSet('compose-drafts', 'draft-1', { expectedVersion: one.version, mutationId: 'finalize-draft-1', data: { ...one.data, status: 'finalized' },});Writes are compare-and-set. A stale expectedVersion is 409; replaying the same mutationId with the same payload returns the stored record instead of writing twice. A delete ({ expectedVersion, mutationId, deleted: true }) is terminal: the author gets the slot and the bytes back, and the id stays a tombstone that refuses re-creation, charged to the app’s byte budget, until the app purges it with records.purge(namespace, { author } | { tombstones: true }) (app session only; a customer gets 403).
Limits, enforced on the host:
- Payload:
datais at most 16 KiB of JSON per record; over that is400. - Per customer: 32 records and 256 KiB. Per app: 512 records and 4 MiB. Over a quota is
429.
Every call is served by the app’s home cluster and stored encrypted with the app’s data key, never as plaintext, so it is the same record whichever cluster your request entered. An unreachable home answers 503, never a stale local copy.
Binary-safe storage.download() (+ downloadWithMeta)
Section titled “Binary-safe storage.download() (+ downloadWithMeta)”baas.storage.download(cid) returns the raw file bytes as a Uint8Array — never JSON-parsed or text-decoded, so images and other binaries come back intact. When you need to echo the content-type back to your own client (which a bytes-only download can’t recover), use downloadWithMeta — it returns the bytes plus the host-supplied contentType and, when present, filename.
const bytes = await baas.storage.download(cid); // Uint8Arrayconst { bytes: b, contentType, filename } = await baas.storage.downloadWithMeta(cid);functions.signCode() — automatic on deploy/eval, and how to make it MEAN something
Section titled “functions.signCode() — automatic on deploy/eval, and how to make it MEAN something”The host requires every function code to carry an Ed25519 signed-code envelope. deploy() and eval() auto-sign when you don’t supply signedCode, so you never re-implement the scheme. Call signCode() directly only if you want to inspect or pre-build the envelope.
await baas.functions.deploy({ name: 'f', runtime: 'nodejs20', code, codeType: 'inline' });const envelope = await baas.functions.signCode(code); // { code, hash, signature, publicKey, timestamp }const stored = await baas.functions.getCode('fn-1'); // fetch the deployed function's code backGetting a real, registerable identity.
-
Generate a persistent keypair once and configure it on the client — every subsequent
signCode()call (including thedeploy()/eval()auto-sign path) reuses the SAME key instead of a throwaway one:import { BaasClient, generateBaasFunctionSigningKey, baasFunctionSignerAllowlistHex } from '@hsuite/smart-engines-sdk';// Once, ever — persist privateKey yourself (env var, secrets manager, etc.).const { privateKey, publicKey } = await generateBaasFunctionSigningKey();console.log(await baasFunctionSignerAllowlistHex(publicKey)); // hand this hex string to your cluster operatorconst baas = new BaasClient({ hostUrl, appId, functionSigningKey: { privateKey } });await baas.functions.deploy({ name: 'f', runtime: 'nodejs20', code, codeType: 'inline' }); // same key every time -
Your cluster operator registers that hex string in the host’s trusted-signer allowlist for your
appId. -
Only once that registration exists, set
functionSigningKey.domainBind: trueto switch to the stronger, appId-bound signature format the armed allowlist checks against.
Smart-agents — register, execute, certify
Section titled “Smart-agents — register, execute, certify”Smart-agents are rule-gated autonomous identities that run capabilities (a verb the agent declared at registration, e.g. payout, notify). On the live chains an agent is an XRPL agent: its wallet is payer-funded and created with the two-phase entities.prepareCreateAgentWithIntent (or prepareCreateAgent) → entities.executeCreateAgent flow, shown under Entities below.
agents.registerWithIntent registers an agent synchronously: the owner signs an app-bound creation intent over the agent’s canonical rules, and the SDK refuses a malformed registration before any call. primaryChain is required and must be one of AGENT_SYNC_CHAINS (polkadot, bitcoin, solana). All three are Coming next, so this path serves no live chain yet.
const agent = await baas.agents.registerWithIntent( { name: 'Payout Bot', type: 'custom', primaryChain: 'solana', capabilities: ['payout'], rulesConfig: { offchain: { allowedUrls: ['https://api.example.com'] } }, }, { signer: ownerSigner }, // signs the creation intent; rule defaults to buildDefaultAgentValidatorRules());// agent.agentId, agent.status, agent.entityIdagents.execute() is the chain-agnostic general form of an agent action — fund/trade/withdraw are conveniences over it.
const res = await baas.agents.execute('agent-1', { capability: 'payout', chain: 'xrpl', // required only for capabilities with on-chain steps params: { destination: 'rDest...', amount: '5' },});// res.steps[]: each { kind: 'onchain' | 'offchain' | 'function', status, transactionBytes?, ref?, detail? }// res.awaitingApproval / res.pendingOpId when a step pauses for owner approval.On-chain steps go through the same rule checks and threshold approvals as every other entity operation; off-chain steps can reach only allow-listed URLs. Fetch an agent’s identity + rule refs + recent on-chain activity with agents.certification(agentId). The capability concepts live in the Smart Agents section.
Entities — two-phase payer-funded create
Section titled “Entities — two-phase payer-funded create”baas.entities is the canonical smart-app authoring surface for chain-bound entities — a token, account, topic, or agent, each governed by a published ruleRef. On the live chains (XRPL and Hedera), where the validators never front a fee, entity creation runs a payer-funded two-phase flow: the cluster runs the per-entity DKG and hands back an unsigned funding transaction; the caller signs and submits it with their own wallet (the SDK never holds the customer’s key nor opens a chain connection); then the cluster finalizes the binding.
sequenceDiagram
participant App as Your app
participant E as baas.entities
participant V as Cluster — per-entity DKG
participant Chain as Blockchain
App->>E: prepareCreateAccount chain + ruleRef
E->>V: POST /api/v3/baas/entities/prepare-create
V->>V: run per-entity DKG, build unsigned funding tx
V-->>App: PreparedEntityCreation with entityId + prepared bytes
App->>Chain: sign + submit the funding tx with your wallet
App->>E: executeCreateAccount entityId + chain
E->>V: POST /api/v3/baas/entities/execute-create
V-->>App: EntityCreationResult { entityId, chainAccounts, ... }
The convenience creators (createAccount / createToken / createTopic) wrap both phases behind a single call — you pass a fundWith callback that funds the prepared transaction, and the SDK drives prepare → fund → execute for you. entities.createAgent is different: it creates a synchronous, platform-paid agent on the coming-next chains (polkadot, bitcoin, solana) only, and refuses xrpl. An XRPL agent always uses the two-phase prepareCreateAgent → fund → executeCreateAgent flow, which runs on XRPL only (the cluster refuses it on any other chain with 400):
import { Rules } from '@hsuite/smart-engines-sdk';
// An agent rule must grant something: build() refuses one with no actionTypeRestrictions,// or one allowing transfer without a recipientAllowlist.const { ruleRef } = await baas.rules.publish( Rules.forAgent() .withSecurity('full') .withActionTypeRestrictions({ allowed: ['transfer'] }) .withRecipientAllowlist({ addresses: ['rPayeeAddress'] }) .build(),);
const account = await baas.entities.createAccount({ chain: 'xrpl', ruleRef, fundWith: async (prepared, { address, reserveRequirement }) => { // Fund the prepared Payment with YOUR wallet, then EITHER return // { signedFundingBlob } for the validators to submit, OR submit it // yourself and return nothing once it has validated. return { signedFundingBlob: await wallet.signAndSerialize(prepared[0]) }; },});An XRPL agent, or any create you want to drive yourself (e.g. an app that funds an agent on a customer’s behalf with its own treasury key), calls the two-phase methods directly:
const prep = await baas.entities.prepareCreateAgent({ primaryChain: 'xrpl', name: 'Payout Bot', ruleRef });// ... app signs + submits prep.prepared[0] with its treasury key ...const created = await baas.entities.executeCreateAgent({ entityId: prep.entityId, chain: 'xrpl' });For an agent, a relayed signedFundingBlob must be the prepared Payment: from the payer the draft was prepared for, to the draft’s address, of XRP and not a partial payment (only that creates the address). The prepared payment’s own LastLedgerSequence is 30 ledgers (about two minutes) past its prepare.
While the draft’s address is not funded, the cluster relays the blob. It needs a LastLedgerSequence at most 216 ledgers past the cluster’s validated ledger: the cluster’s own 200-ledger threshold-signing window, plus 16 for a wallet whose server is ahead of the cluster’s (400 otherwise). A payment the ledger fails, one signed after its ledger window closed, or one whose sequence the payer’s wallet spent on another transaction answers 400 and leaves the draft as it was: call executeCreateAgent again with a new payment. A draft relays at most 8 payments; after that, pay its address from the payer’s own wallet.
Once the draft’s address is funded, the cluster relays nothing: a signedFundingBlob that can no longer apply is accepted and the draft finalizes (the ledger already applied it, as for a wallet that submits the payment as it signs and hands the blob back, or its sequence or ledger window is spent; a ticketed one only once its window closed); one that may still apply is refused (400), so execute without it.
If you submit the payment yourself, execute once it validated: until the ledger holds the draft’s address, executeCreateAgent answers 409, which you may retry. If the draft’s setup transaction fails on the address’s reserve (409), top the address up from the payer’s own wallet and execute again: the cluster signs it again only once the address changed.
A draft nobody executes within 24 hours is cancelled and its agent slot comes back, unless its address holds funds; that draft stays executable.
Each arm has its own prepareCreate* / executeCreate* pair: prepareCreateAccount, prepareCreateToken, prepareCreateTopic (Hedera), and prepareCreateAgent. Hedera threads the prepared create’s transactionId back as createTxId so the cluster resolves the network-assigned 0.0.X id from the receipt.
Async creates — wait by default, or follow the operation (SDK 12.0.0)
Section titled “Async creates — wait by default, or follow the operation (SDK 12.0.0)”Creating an entity is a long ceremony (a per-entity DKG, chain round-trips) — long enough that a dropped HTTP connection used to leave you unsure whether the entity was minted. SDK 12.0.0 closes that window: wherever the host admits the create asynchronously, the create method is admitted with wait: false and a once-per-call idempotency key, journaled server-side, and the SDK then follows the operation to its outcome over GET /api/v3/baas/operations/:id — resolving the same EntityCreationResult (or PreparedEntityCreation) it always did. Your existing code is unchanged; it just survives a dropped connection now.
Where waiting is the default:
| Flow | Async by default |
|---|---|
Hedera platformPaid createToken / createAccount / createTopic |
✅ the single-shot create is waited |
XRPL token + account drafts — prepareCreateToken / executeCreateToken / prepareCreateAccount / executeCreateAccount, and the XRPL fundWith conveniences |
✅ async prepare → fund → async execute, both waited |
Hedera fundWith two-phase and createAgent |
❌ stays synchronous (the host does not admit it async) |
Want the ticket instead of the dish? Pass { wait: false } and the single-shot routes (and the prepareCreate* / executeCreate* halves) return the 202 admission — an AdmittedOperation { operationId, status } — instead of blocking. Narrow the union with baas.isAdmittedOperation (from the SDK’s baas namespace export), then follow it yourself:
import { baas as sdk } from '@hsuite/smart-engines-sdk';
// Admit now, follow later — survives a process restart (the idempotency key is yours).const admitted = await baas.entities.createToken( { chain: 'hedera', name: 'My Token', symbol: 'MYT', decimals: 6, ruleRef, platformPaid: true }, { wait: false, idempotencyKey: myUuid },);
if (sdk.isAdmittedOperation(admitted)) { console.log(admitted.operationId, admitted.status); // e.g. 'pending'
// One snapshot, answerable from any cluster: const snap = await baas.operations.get(admitted.operationId);
// …or block until it reaches its terminal state and unwrap the entity: const final = await baas.operations.wait(admitted.operationId); // → OperationStatus { status: 'finalized', finalized, ... } console.log(final.finalized);}A few things worth knowing:
CreateCallOptions(the trailing bag on every create method):wait(defaulttrue),idempotencyKey(generated once per call when omitted; supply your own to make retries idempotent across process restarts),waitTimeoutMs(default120_000),pollIntervalMs(default1_500),wakeChannel, andsignal.- A timeout is not a failure. When the client wait budget expires while the operation is still running,
operations.waitrejects withOperationPendingError(carrying theoperationId) — the ceremony keeps running server-side, so re-waitthe id or polloperations.get. A genuinely aborted operation rejects withOperationAbortedError. Both are exported on thebaasnamespace. - Retrying the create is safe. The admission is sealed at
(appId, kind, idempotencyKey), so a retry is answered with the first admission’s operation — never a second ceremony, never a second entity. baas.operationsis the follow surface:get(id)for one snapshot,wait(id, { until, timeoutMs, pollIntervalMs, wakeChannel, signal })to follow to terminal state (until: 'prepared'resolves as soon as a prepare draft exists). The orchestratedfundWithconvenience has no single admission to hand back, so an explicit{ wait: false }on it is refused — driveprepareCreate*/executeCreate*with{ wait: false }instead.
Entity value-movement
Section titled “Entity value-movement”Once an account entity exists, move value from it with the entity-scoped methods — transfer, prepareWithdraw / executeWithdraw, mint, burn, compliance, and trustline (a topic entity takes submitMessage, below). The entity is always the source / treasury / authority and fee-payer, resolved server-side from the entity record — so a caller can never name a foreign payer or drain another account. The host never pays. On Hedera and XRPL, once a transfer or withdrawal carries every signature it needs, the validators broadcast it and return the on-chain result (broadcast: true). A rule deny surfaces as an HTTP 403, never as signed bytes.
const result = await baas.entities.transfer('entity-1', { chain: 'xrpl', to: 'rDest...', amount: '1000000', // smallest unit (drops) tokenId: 'HST.rIssuer...', // omit for a native transfer});// result.broadcast === true → result.status is the on-chain resultThe full walkthrough is on Transactions.
HCS topics: create + submit
Section titled “HCS topics: create + submit”A Hedera topic entity’s submit_key is the topic’s own per-entity DKG KeyList, so your app cannot write to it with its own @hashgraph/sdk operator: only a validator-signed submit works. submitMessage drives that for you.
-
Publish a topic rule with submit enabled.
operations.submit.enabledmust betrue(optionallymaxMessageSize,rateLimit,payloadSchema). Without it the validators deny every submit with403. -
Create the topic:
const topic = await baas.entities.createTopic({ chain: 'hedera', ruleRef, fundWith }). -
Submit:
const { transactionId } = await baas.entities.submitMessage(topic.entityId, {message: JSON.stringify({ event: 'order.created', id: 42 }),payerAccountId: '0.0.1234', // omit to use the session walletfundWith, // signs + submits the prepared message with the PAYER's wallet});
The host resolves the topic id from the entity record (never from your request), the validators check the topic’s rule and attach the topic submit-key signatures, and you sign + submit as the payer: the platform never pays the Hedera fee. To drive it yourself, call baas.entities.prepareSubmitMessage(entityId, { message, payerAccountId }) (POST /api/v3/baas/entities/:entityId/message) and submit transactionBytes with your wallet.
- 1024-byte limit. One HCS chunk. The SDK counts UTF-8 bytes (not characters) and refuses an empty or oversize message before any call; the host and validator enforce the same cap.
baas.messagingis not HCS. It is off-chain pub/sub channels. Useentities.submitMessagefor on-ledger HCS messages.
AI inference (provider-agnostic)
Section titled “AI inference (provider-agnostic)”The SDK ships a standalone, provider-agnostic LLM helper for smart-app authors: call Anthropic, OpenAI, or Gemini behind one interface. It is a pure utility — no validator round-trip, usable in any runtime with a global fetch — and follows Model 1: the caller supplies their own API key, and the SDK never persists or proxies it.
import { createModelProvider, inferJson } from '@hsuite/smart-engines-sdk';
// One-shot text — pick the vendor at runtime by name.const ai = createModelProvider({ provider: 'anthropic', apiKey, model: 'claude-3-5-sonnet-latest' });const res = await ai.infer({ system: 'You are terse.', messages: [{ role: 'user', content: 'Hi' }] });if (res.ok) console.log(res.text);
// Structured JSON — never throws; extracts + validates a single JSON object, one retry.const out = await inferJson<{ score: number }>({ provider: 'openai', apiKey, model: 'gpt-4o-mini', system: 'Rate the sentiment 0-1.', input: { text: 'I love this' }, parse: (raw) => MySchema.parse(raw), // optional caller-supplied validation});if (out.ok) console.log(out.value.score);inferJson never throws — inspect its ok flag. The openai provider also accepts a baseURL for an OpenAI-compatible endpoint (e.g. a local Ollama server), in which case apiKey may be omitted.
Guarding your own backend (@hsuite/smart-engines-sdk/nestjs)
Section titled “Guarding your own backend (@hsuite/smart-engines-sdk/nestjs)”Your app’s backend receives the end user’s session as an ordinary Authorization: Bearer <token> header. CustomerSessionGuard validates it against the host and hands you the token to forward — so the session that authenticated the caller is the same one the platform scopes on, with no bearer parsing of your own.
As of SDK 7.0.0, importing the module is all it takes. forRoot / forRootAsync register CustomerSessionGuard as an APP_GUARD for you, so every route is authenticated the moment the module is imported — you no longer wire the guard into your own providers. (Before 7.0.0 the guard was not registered for you, so an app that imported the module booted with no global authentication.) The trade-off is the migration below.
import { Controller, Get, Inject, Module } from '@nestjs/common';import { BaasClient, CustomerSession, Public, SDK_BAAS_CLIENT, SmartEngineModule,} from '@hsuite/smart-engines-sdk/nestjs';
@Module({ imports: [ SmartEngineModule.forRoot({ validatorBaseUrl: process.env.SMART_HOST_URL!, appId: process.env.APP_ID, // binds customer sessions to THIS app }), ], // No APP_GUARD provider needed — the module registers CustomerSessionGuard itself.})export class AppModule {}
@Controller('orders')export class OrdersController { constructor(@Inject(SDK_BAAS_CLIENT) private readonly baas: BaasClient) {}
@Get('health') @Public() health() { return { ok: true }; } // opt a route out
@Get() list(@CustomerSession('customerToken') token: string) { return this.baas.forCustomer(token).db.find('orders'); // only the caller's orders }}It fails closed: no bearer on a guarded route → 401; a bearer the host rejects → 401; a bearer minted for another app → 401 (the session’s appId is compared against the appId in your module config); no BaaS client bound → 401. There is deliberately no “open mode” switch — a route that should be reachable without a session says so with @Public().
The 401 body names the flow, never your host address — it is a fixed string (CUSTOMER_SESSION_REQUIRED_MESSAGE), because the host URL a deployed app resolves is an internal address that anonymous callers should not learn.
@AppOwnerOnly() — restrict a route to your own developer wallet
Section titled “@AppOwnerOnly() — restrict a route to your own developer wallet”CustomerSessionGuard answers “who is calling”; nothing in a customer session answers “may they”. An admin/analytics route that must serve only the app’s developer — not any authenticated customer — used to force apps to hand-roll that owner check, which is easy to get wrong. SDK 7.0 ships the check as a decorator: mark the route @AppOwnerOnly() and give the module the wallet that owns the app.
import { AppOwnerOnly, SmartEngineModule } from '@hsuite/smart-engines-sdk/nestjs';
SmartEngineModule.forRoot({ validatorBaseUrl: process.env.SMART_HOST_URL!, appId: process.env.APP_ID, appOwnerWallet: process.env.APP_OWNER_WALLET, // the developer wallet that minted this app});
@Controller('admin')export class AdminController { @Get('analytics') @AppOwnerOnly() // 403 for any caller whose wallet ≠ appOwnerWallet analytics() { /* ... */ }}SmartEngineModule registers AppOwnerGuard as a second APP_GUARD, after CustomerSessionGuard, so importing the module is enough — the decorator is inert metadata otherwise. The caller’s wallet must equal appOwnerWallet. Verdicts:
- route not
@AppOwnerOnly()→ untouched; - no customer session for this app (anonymous, or a session minted for another app) →
401; appOwnerWalletabsent from config →403(logged loud — it fails closed, never falls through to “allow”);- wallet mismatch →
403; - the owner → allowed.
@Public() opts a route out of authentication; it must never opt one out of authorization, so an owner-only route still refuses an anonymous caller. The 403 body is a single fixed string (app-owner only) for both a non-owner and an app that never configured an owner — a caller cannot use it to tell one from the other.
SCOPED_BAAS_CLIENT — a per-request client already bound to the caller
Section titled “SCOPED_BAAS_CLIENT — a per-request client already bound to the caller”forCustomer(token) is opt-in per handler, and a handler that forgets it runs with the app’s tenant-wide authority. SDK 7.0 turns “act for the customer” into construction: inject SCOPED_BAAS_CLIENT and you get a request-scoped BaasClient.forCustomer(...) already bound to the session CustomerSessionGuard stamped — a service holding it cannot make a tenant-wide call, because it never holds the unscoped client.
import { Controller, Get, Inject } from '@nestjs/common';import { SCOPED_BAAS_CLIENT, CustomerScopedBaasClient } from '@hsuite/smart-engines-sdk/nestjs';
@Controller('orders')export class OrdersController { constructor(@Inject(SCOPED_BAAS_CLIENT) private readonly baas: CustomerScopedBaasClient) {}
@Get() list() { return this.baas.db.find('orders'); // already scoped to the calling customer }}It fails closed: used on a @Public() route, or anywhere the guard did not run (so no customer token is on the request), the first data-plane access throws 401 — it never degrades to the singleton. Inject SDK_BAAS_CLIENT instead only where the app deliberately acts as itself (tenant administration), and say so.