@ptengine/app-sdk
v2.8.2
Published
Internal contract package for Ptengine X custom app integration (types + manifest schema). Not a general-purpose library.
Downloads
2,890
Readme
@ptengine/app-sdk
Contract package for Ptengine Custom Apps — third-party or in-house apps that run inside the Ptengine platform. It ships:
- TypeScript types for the host bridge:
PtApp/PtAppContext/PtAppHost - the JSON Schema for
manifest.json(manifestSchema) and its TS typeCustomAppManifest installDevHost()— a fake host for local development, so you can build without the platformdist/app-sdk.iife.js— the runtime client the platform injects into your app. The platform backend hosts and serves it; you never reference it yourself.
Repository: gitlab.com/PtmindDev/custom-apps/app-sdk
(standalone repo, published to npm; release process in RELEASING.md).
Start here
Most developers should not wire this package up by hand. Scaffold from the official starter and follow the skill — they encode the current conventions, the build config, the packaging step and the publish flow, and they stay in sync with the platform:
- Starter:
ptengine-app-starter(take the newestv3.*tag) - Skill:
ptengine-custom-app - Ptengine MCP (validate your analytics questions before writing query code): https://helps.ptengine.com/en/ai/mcp
Version floors enforced by the starter's npm run doctor (starter v3.3.0):
@ptengine/app-sdk ^2.4.0 and @ptengine/app-backend ^0.4.0. Note that 0.x caret ranges do
not cross minors — ^0.2.0 never reaches 0.4, so edit the range and reinstall.
Read this README when you need the exact contract: field names, error codes, event names.
Install
npm install @ptengine/app-sdkHow your app is loaded
Your app is loaded by the Ptengine platform inside a real cross-origin <iframe>: your
bundle is served from a separate sandbox domain, the browser isolates it, you cannot read the
platform's cookies or localStorage, and parent.document throws SecurityError. That is by
design. The only channel between the platform and your app is postMessage, and on top of it
the platform installs window.PtApp, which carries:
context: PtAppContext— the current site/session context (appId/sid/locale/theme/initialPath, plus the optionaluser { id, email, name }since 2.4.0)- host capabilities —
ui(toast/confirm/overlay),nav(push/syncRoute),data(query/describe),auth(getAppToken),ai(provideContext), andon()
Types:
import type { PtApp, PtAppContext, PtAppHost } from '@ptengine/app-sdk';export interface PtAppContext {
appId: string;
sid: string;
locale: string;
theme: 'light' | 'dark';
initialPath: string;
/**
* The person currently using the app (injected by the host since 2.4.0; older hosts do not
* have it, so null-check before reading). Display and attribution only — never a permission
* check. For a trusted identity server-side, read the App Token
* (`ctx.auth.userId` / `.email` / `.name`).
*/
user?: { id: string; email: string | null; name: string | null };
}
export interface PtAppHost {
toast(message: string, type?: 'success' | 'error' | 'info'): void;
confirm(opts: { title?: string; message: string }): Promise<boolean>;
overlay(theme: 'white' | 'black' | null): void;
navPush(path: string): void;
syncRoute(subPath: string): void;
data: PtAppData;
}Read what you need off window.PtApp in your own code (window.PtApp.context.sid,
window.PtApp.ui.toast('Saved'), window.PtApp.ui.overlay('black')). This package wraps
nothing at runtime — it only provides types and schemas.
The app's identifier is assigned by the platform
Nobody types an app identifier. When an app record is created, the platform assigns its address
as <manifest id, or a slug of the name>-<8 hex characters> (for example
user-persona-6282c7fb). It is globally unique, assigned once, and cannot be changed
afterwards. manifest.id only seeds the readable prefix — it is never used verbatim, so the
same bundle can be uploaded into any number of workspaces without colliding.
Read the identifier from context.appId; never hard-code it.
Subscribing to host events: PtApp.on
on accepts exactly three events, each with its own callback shape:
on('context', ctx => ...)— the host pushed a new context (locale / theme changed).on('route', ({ subPath }) => ...)— host route change (browser back/forward); your app should move its own router to match.on('overlay.click', () => ...)— the user clicked the host overlay rendered byui.overlay; your app decides whether to close its own layer.
on() returns no unsubscribe function — guard stale callbacks with your own flag.
DOMContentLoaded / load timing
Your app runs in a real iframe as a normal, complete document: DOMContentLoaded and
load fire natively, at exactly the same time as when you open the page under your local
npm run dev. The platform neither re-dispatches nor rewrites them, so you never need
platform-specific startup logic.
(The micro-frontend approach used before 1.0.0 did re-dispatch these events. That mechanism
is gone.)
⚠️ One thing differs: window.PtApp readiness has nothing to do with those two events. In
hosted mode the platform injects the SDK loader as a blocking <script> in <head>, so
window.PtApp already exists when your code runs; but in "local dev server loaded inside the
platform" mode it becomes ready asynchronously — see the installDevHost() section below.
manifest.json
Every app needs a manifest.json at the root of the zip (built and shipped alongside your
output directory), describing the app so the platform can validate and mount it. The JSON
Schema is exported by this package:
import { manifestSchema } from '@ptengine/app-sdk';
// or reference the schema file directly (no JS runtime involved):
// require.resolve('@ptengine/app-sdk/manifest.schema.json')The matching TS type:
import type { CustomAppManifest } from '@ptengine/app-sdk';Template (manifest.json):
{
"schemaVersion": 1,
"version": "1.0.0",
"entry": "index.html",
"id": "app_my_demo",
"display_name": { "zh-CN": "我的应用", "en-US": "My App" },
"icon": "icon.png",
"mode": "inline",
"mount": { "type": "nav" },
"scopes": ["analytics:read"],
"sdkVersion": "1.0.0",
"ai": { "exclude": [".patient-notes"] }
}Only three fields are required: schemaVersion, version (your app's own version) and entry
(the built entry HTML filename, relative to the manifest's directory). Everything else is
optional, and unrecognized extra fields do not fail validation.
version belongs to the app and must increase on every upload — the package.json version
is ignored.
mode/mount.type: the platform currently implements onlyinline/nav. The other enum values are reserved contract slots — setting them will not error, but will not change how the app is loaded either.
schemaVersion
1— front end only.2— the manifest may carry abackendsection (a Cloudflare Worker the platform deploys for you and routes/api/*to).
The platform routes the two through completely different publish pipelines, so do not bump to
2 just to use a new field: a v2 bundle goes down the backend path, which a front-end-only app
has no reason to enter. Conversely, a backend section under schemaVersion: 1 is silently
ignored — the front end works and every /api/* request 404s.
The backend manifest section is not documented here. See
@ptengine/app-backend and the
ptengine-custom-app skill
(references/backend-runtime.md) for entry, routes, resources, migrations, secrets,
vars, egress and compatibilityDate.
App name (display_name)
display_name is the localized display name (e.g. { "zh-CN": "我的应用", "en-US": "My App" }).
Every upload overwrites the app record's name from the manifest, collapsing the localized map to the current UI language. That includes a name someone edited on the management page: a hand-typed name from app creation wins only until the next upload. If a name must stay put, put it in the manifest and keep it there — renaming only in the product UI survives exactly until the next release.
App icon (icon)
icon is a path relative to the manifest inside the zip, e.g. assets/icon.svg.
- Every upload overwrites the app record's icon from the manifest, admin-page edits
included — same rule as
display_name. - If no
iconis declared, the app keeps whatever icon it has (the auto-generated monogram tile, or one chosen in the product). An absent declaration never blanks the icon. - On upload the platform checks that the file really exists inside the package; if not, the
upload fails with
ICON_NOT_FOUND. The extension must be on the asset whitelist (.svg/.png/.jpg/.jpeg/.webp/.ico). - Use a square image, 128×128 or larger, with a transparent background (it is placed inside the platform's rounded container).
Because both fields are part of the release, changing either re-brands the app for everyone on the next upload. Change them deliberately.
AI assistant (ai)
Users can open the AI assistant panel on the right of your app page and let it read what is currently visible on the page as conversation context.
You do not have to do anything for this. The platform ships a DOM extractor that reads the visible content on screen; whether it is enabled is decided by a workspace admin toggle on the app management page, not by your package.
The ai section is optional and expresses the three things only you (the app author) know:
{
"ai": {
"enabled": true,
"exclude": [".patient-notes", "#internal-memo"],
"includeInputValues": false,
"launcher": { "position": "bottom-left" }
}
}| Field | Meaning |
|---|---|
| enabled | Your stance. Omitted = no stance (the workspace may enable it); true = endorsed, works out of the box; false = explicit refusal, the workspace cannot enable it from the platform side |
| exclude | CSS selectors to skip entirely during extraction. Matched elements and all their descendants stay out of the context |
| includeInputValues | Whether to extract values the user has typed into forms. Default false |
| launcher.position | Suggested default position of the launcher bubble (bottom-right / bottom-left); the workspace can override it. Useful when you already have a floating button in one corner |
exclude is the field worth your time here. Everything visible on the page is context by
default, and only you know which block must not leave the page — the workspace admin cannot see
your DOM and cannot write this rule for you. If your page shows medical records, phone numbers
or internal notes, declare exclude first, then let the admin flip the toggle.
Two hard security defaults are enforced inside the platform and are unaffected by any
configuration (including includeInputValues: true):
- the value of
<input type="password">is never extracted type="hidden"fields are skipped entirely
In addition, <script> / <style> / <svg> / <canvas> / <math> subtrees never enter the
context (text-bearing information only), and elements with display:none /
visibility:hidden / opacity:0 are not extracted — what gets extracted is what the user
can actually see right now.
Custom extraction (rarely needed)
When content lives in a <canvas> or a shadow DOM, or you want a more structured
representation than the DOM gives, override the default extractor:
import type { PtAiSnapshot } from '@ptengine/app-sdk';
window.PtApp.ai.provideContext(opts => ({
nodes: [
{ t: 'h', l: 1, x: 'Orders this month' },
{ t: 'kv', k: 'Total', v: '¥128,400' },
{ t: 'table', head: ['Order', 'Amount'], rows: [['SO1001', '¥299']], n: 1284 }
],
truncatedAtSource: false,
url: location.href
}));- It must return synchronously. Returning a Promise breaks the postMessage structured
clone; on the platform side this shows up as a silent timeout, not an error, and is hard
to diagnose. The dev host's
__snapshot()catches this locally. optscarries theexclude/includeInputValuesvalues passed through from the manifest. Honoring them is your implementation's job — overriding the default extractor means taking over the semantics of those two fields.- A
tablenode'snis the real total row count;rowsmay contain only the first few. The platform uses it to tell the AI that this is an excerpt. - Passing a non-function (including
null) clears the override and restores the platform's default extractor.
Local check (under the dev host):
const app = window.PtApp as PtApp & { __snapshot(opts?: unknown): unknown };
app.__snapshot({ exclude: ['.pii'] }); // returns null with a hint if no provider is registeredPermissions (scopes)
Like a Chrome extension manifest's permissions: an app declares in manifest.json what it
needs, so the platform and workspace admins can review what it will access.
| Scope | Meaning |
|---|---|
| analytics:read | Read site analytics data (visits / events / conversions) |
| profile:read | Read basic profile information (name, timezone, settings; no personal data) |
| user:read | Read visitor / user data (profiles, user properties; the most sensitive — declare only when genuinely needed) |
| ui:notify | Use host UI capabilities (window.PtApp.ui toast / confirm) |
| asset:read | Read the Drive asset tree as the current user (PtApp.asset.* in the front end, ctx.pt.asset.list/get/sharedWithMe in the backend with @ptengine/app-backend ≥0.8.0). A read scope: visibility equals exactly what that user can see in the product; auto-granted for apps the workspace built itself, external apps need admin consent. See Reading assets. |
| asset:write | Write to the product asset library on the user's behalf (create folders / assets, upload files). A write scope — unlike the read scopes above, it always requires explicit admin consent, even for an app the workspace built and uploaded itself. |
TS type:
import type { CustomAppScope } from '@ptengine/app-sdk';Declaration:
{ "scopes": ["analytics:read", "ui:notify"] }Scopes are enforced
- Values are validated on upload. An undefined or misspelled scope (
analytics:write,Analytics:read) fails manifest validation, and so do duplicates. - Data scopes decide whether your backend gets a data gateway binding at all. With no data
scope declared,
ctx.pt.querythrows 501PT_GATEWAY_NOT_BOUNDregardless of what the caller is otherwise allowed to do. - They drive the workspace-admin consent dialog. Until an admin approves the requested
scopes,
PtApp.auth.getAppToken()rejects withPT_CONSENT_REQUIRED(see below). - The data gateway rejects queries whose required scope was not declared or approved, with
SCOPE_REQUIRED(403) orSCOPE_DENIED(403).
A version that asks for more scopes needs approval again: shipping a new scope in version N+1 means every workspace sees the consent dialog on first entry. Ask for the minimum up front and add scopes in a later version. Design a backend so that a rollback to the previous, narrower version still works — guard only the routes that genuinely read platform data, so the rest degrades instead of failing wholesale.
Admin consent and PT_CONSENT_REQUIRED
An app with a backend calls await window.PtApp.auth.getAppToken() to mint the App Token it
sends to its own worker. That call can fail with PT_CONSENT_REQUIRED
(PT_BRIDGE_ERRORS.consentRequired): the app's requested scopes have not been approved by a
workspace admin yet, or a new version is asking for more.
This code is retryable. The host shows the consent dialog; once the admin approves, call
getAppToken() again and you get the token — no page reload needed, because the SDK caches
successful tokens only, never failures. If the current user is not an admin, tell them to ask
one rather than looping.
Contrast with the other failure code: PT_AUTH_UNSUPPORTED means the app has no backend at
all (or has never published a version). Retrying never helps. Only PT_CONSENT_REQUIRED is
worth a retry.
import { PT_BRIDGE_ERRORS } from '@ptengine/app-sdk';
try {
const token = await window.PtApp.auth.getAppToken();
} catch (e: any) {
if (e?.code === PT_BRIDGE_ERRORS.consentRequired) {
// the host has shown the consent dialog; retry after approval
}
}Local development: the dev-host mock
You do not need the platform to start building. Call installDevHost() once in your app entry
(as early as possible, before rendering) and the window.PtApp the platform would inject is
installed in your browser:
// top of main.ts / index.tsx
import { installDevHost } from '@ptengine/app-sdk';
if (import.meta.env.DEV) {
await installDevHost();
}The default injected context:
{ appId: 'dev-app', sid: 'dev-sid', locale: 'zh-CN', theme: 'light', initialPath: '',
user: { id: 'dev-user', email: '[email protected]', name: 'Dev User' } }Override any of it:
installDevHost({ context: { locale: 'en-US', theme: 'dark' } });Locally, window.PtApp.ui / .nav methods console.log their arguments (confirm degrades to
the browser's native window.confirm). There is no real platform interaction, but calls like
window.PtApp.ui.toast(...) / .nav.push(...) run end to end instead of throwing on
undefined. The fake host's PtApp.asset.list / .sharedWithMe always return an empty list
({ items: [], nextCursor: null, hasMore: false }) — test real asset browsing inside the platform.
Two environments, two behaviours
installDevHost() detects whether it is inside a real host — you do not write that if
yourself. The one and only criterion is window.parent !== window.self (am I inside
someone's iframe). That is settled the moment the page loads and has nothing to do with whether
host data has arrived; deciding by "does host data have a value" silently misfires on async
races.
- Plain local
npm run dev(window.parent === window.self): installs the fake host described above. - Local dev server loaded inside the Ptengine platform (e.g. pointing the app's local dev
entry at
http://localhost:5173from the management page): never installs the fake host. InsteadinstallDevHost()posts ahelloto the host, waits for aninitcarrying aMessagePortto complete the handshake, stashes the result, and then dynamically inserts the platform's real SDK loader from thesdkUrlininitso it takes over. You get the realcontext(realsid/locale/theme) and real host capabilities (ui.toastreally shows a toast,nav.pushreally navigates), not fake values.
If no host handshake arrives within 5 seconds inside the platform, a console.error is logged
and window.PtApp is not installed. That is deliberate: falling back to the fake host would
leave you debugging against dev-app / dev-sid placeholder data for hours.
⚠️ In platform dev mode, window.PtApp becomes ready asynchronously
In hosted mode the platform injected the SDK loader as a blocking <script> in index.html's
<head> at upload time, so window.PtApp exists before your code runs. Not so when loading a
local dev server inside the platform: installDevHost() inserts a real <script> into
<head> to fetch the platform SDK, and window.PtApp is only attached once that script
finishes loading. If your entry code reads it synchronously (e.g. useMemo(getPtApp, []), which
runs once on first render), it will read null forever.
Wait for readiness in one of two ways:
// 1: await — installDevHost() returns Promise<void>
await installDevHost();
createRoot(el).render(<App />);// 2: listen for pt-app-ready (for callers that cannot easily make the entry async)
import { PT_APP_READY_EVENT } from '@ptengine/app-sdk';
installDevHost();
window.addEventListener(PT_APP_READY_EVENT, () => {
// window.PtApp is ready here
});The plain local npm run dev branch (fake host) completes synchronously, so both the await
and the event return/fire immediately with no extra wait — you can use either form uniformly.
pt-app-ready is dispatched in both modes (in hosted mode the platform-injected SDK
dispatches it once when the handshake connects; re-handshakes do not re-dispatch), so form 2 is
the cross-mode way to write it.
⚠️ In hosted mode window.PtApp exists synchronously, but context does not
This is the easiest trap. In hosted mode a blocking <script> is injected, so your code can
read window.PtApp immediately — but context only arrives with the host's postMessage
handshake, and until then PtApp.context is an empty object {} (not null, not
undefined).
{} is truthy, so guards like if (!app.context) … fail silently and your code runs on with
an empty sid, empty locale and empty initialPath. Typical symptoms: the first query goes
out with an empty sid; the UI language falls back to the default; and the initialPath sent
by the platform is lost, so deep-link restore is permanently broken — especially with
initializer-runs-once patterns like useState(() => parse(app.context.initialPath)).
Render your UI after the context is ready:
// use appId to test readiness (it does not exist before the handshake)
const ready = (ctx) => (ctx && ctx.appId ? ctx : null);
let ctx = ready(window.PtApp.context);
window.PtApp.on('context', next => {
ctx = next; // pushed once at handshake, then again on locale / theme changes
render();
});
if (ctx) render();ui.* / nav.* / data.* do not need to wait: calls made before the handshake are queued
inside the SDK and flushed in order once connected.
If sdkUrl fails to load (e.g. 404), a console.error with the exact URL is printed; the
installDevHost() promise still resolves (it does not reject, to avoid an unhandled rejection),
but window.PtApp never becomes ready and pt-app-ready is never dispatched.
Two prerequisites for loading a local dev server inside the platform
- Your dev server must not refuse cross-origin framing. The platform points an
<iframe src>straight at your dev server address, so it must not returnX-Frame-Options: DENY/SAMEORIGIN, nor aframe-ancestorsCSP that excludes the platform origin. Vite and webpack-dev-server send neither by default, so usually no configuration is needed — but check any proxy you put in front of them. - Enable CORS. Once the handshake completes everything goes over the
MessagePortand needs no CORS; but when the handshake never arrives, the platform makes one diagnosticfetchagainst the entry URL to distinguish "file is missing" from "file is there but the bridge did not connect". Without CORS that diagnostic degrades to "unreachable" and the error message points in the wrong direction. Vite projects just setserver.corstotrue(ptengine-app-starterships this). The trade-off: while CORS is on, any site on your machine could in theory read this dev server's content — but it only runs on the developer's machine and is not exposed publicly, which is usually an acceptable cost for local integration work.
⚠️ Build config: you must use a relative base
App bundles are loaded from a non-root path inside the Ptengine platform. If your build tool
uses an absolute base (the default), <script src="/assets/xxx.js"> in the output points at the
platform root instead of your app's own directory — assets 404 and you get a blank page.
Vite projects must set this explicitly in vite.config.ts:
export default defineConfig({
base: './'
});The same applies to other build tools: every static asset reference in the output must be
relative, never an absolute path starting with /.
Related: use hash routing (createHashRouter / <HashRouter>). The entry URL is
.../<versionId>/index.html, so history.pushState('/x') escapes the app. Reflect internal
pages with nav.syncRoute('detail'); the platform sends them back as context.initialPath.
⚠️ Do not persist business data in localStorage
Your app runs in a real cross-origin iframe and cannot read the platform's cookies or
localStorage — that is the point of the architecture. Your own origin's localStorage /
sessionStorage still work, but in a cross-site embedding they are partitioned by top-level
site (Storage Partitioning / CHIPS): the same app embedded by different customer sites sees
separate, mutually invisible storage, which the browser may also clear at any time. On top of
that, the sandbox origin is shared by every custom app, so prefix any key with context.appId
and never keep credentials or personal data there.
Do not use it as your persistence layer. State that must survive across sessions belongs in
your own backend (D1 / KV) or must be re-fetched through PtApp.data.
Querying data (PtApp.data)
Inside the platform an app can query live data with
window.PtApp.data.query({ queryType, params }) — the same capability as the Ptengine MCP
server's Run-Query:
const res = await window.PtApp.data.query({
queryType: 'funnel_insight',
params: {
timeRange: { key: 'lastDays', days: 30 },
steps: [{ event: 'product_viewed' }, { event: 'checkout_completed' }]
}
});
// res = { columns: string[], rows: unknown[][], rowCount: number, metadata: object }
// rows is a 2-D array; element order matches columns order. No sql is returned.Queries are host-mediated: the request is authenticated by the platform with the current
session, pinned to the current profile (context.sid), executed server-side, and only the
result comes back. Your app never sees a token and cannot query another profile. Declare
analytics:read in manifest.json's scopes before using it.
Param types are a discriminated union keyed by queryType: params narrows precisely, so
your IDE suggests the right fields and a typo is a compile error — no guessing from docs.
Available queryTypes, param schemas and querying rules live in two files shipped in this
package, generated from the same source of truth as the backend and MCP so they cannot drift:
data-query.llms.txt (human/LLM readable) and data-query.schema.json (JSON Schema).
Rules worth internalizing (fuller version in the skill's references/data-queries.md):
- Never write params from memory — use
describe()or the two generated files. timeRangeis a required object:{ key: 'lastDays', days: 30 },{ key: 'thisMonth' },{ key: 'custom', startTime: '2026/08/01', endTime: '2026/08/20' }(slash format; dates are cut in the site profile's timezone, not UTC or the browser's). Full key list:todayyesterdaythisWeeklastWeekthisMonthlastMonthlastDayscustombeforeafteron. Legacy MCP string presets ('last_30_days') still resolve server-side but are no longer the contract shape and fail type-checking.- Never invent event or property names — a name that does not exist returns 0 rows with no
error. Resolve real names through the MCP server's
List-Catalogfirst. - One analytics question = one query. Group with
dimensionand read the table instead of enumerating candidate values query by query. - Queries are real analytics jobs — seconds, sometimes minutes. Every call needs loading, error
and empty states, and mount-time bursts get throttled (
TOO_MANY_CONCURRENT_QUERIES).
Upstream error codes pass through verbatim, because each is actionable: SCOPE_REQUIRED /
SCOPE_DENIED (403), TOO_MANY_CONCURRENT_QUERIES (429), UNKNOWN_QUERY_TYPE (400). Only an
unparseable envelope degrades to PT_QUERY_FAILED.
18 queryTypes are currently open, exactly aligned with the platform's scenario layer:
| queryType | Purpose |
|---|---|
| traffic_insight | Overall site traffic: 12 fixed metrics, optionally grouped by one visit dimension |
| event_insight | Event-level counts (occurrences / unique users / conversion rate) plus optional event-property aggregation, optionally grouped by one dimension |
| funnel_insight | Ordered (loose-order) event funnel: UV, conversion rate and elapsed time per step, optionally grouped by one dimension |
| path_insight | Multi-hop in-session path flow anchored on a page or event (forward / backward) |
| page_transitions | Anchorless page→page single-hop adjacency edges (in-session LEAD pairing) |
| page_insight | Page-level metrics for a set of pages (URL list and/or pageUrl filter), optionally grouped by one dimension |
| page_block_metrics | Per-block (heatmap zone) metrics for one landing page, bucketed by device |
| page_element_metrics | Per-element impressions / clicks / CTR for a set of pages, plus optional goal conversion |
| experience_search | Search the experiment catalog by name/status (does not hit Doris) |
| experience_report | Overall report for one experiment (not per-variant, no uplift) |
| experience_abtest_report | A/B test detail for one experiment: each variant × goal, plus uplift |
| experiment_attributed_funnel | Attributed impact of a set of experiments on one event funnel (first / last / any touch) |
| user_overview | Profile overview for a single user |
| user_timeline | Summary of a single user's recent sessions |
| user_journey | A single user's full cross-session event trail |
| user_session_detail | Event detail within a single session |
| user_list | Search / list a user population by criteria |
| user_benchmark | One user compared against the site benchmark |
⚠️ The user-level (user_*) scenarios return single-person detail (possibly including email
and cross-session trails). They go through the same authentication and profile pinning as the
aggregate scenarios, but controlling who inside your app may see that data is your
responsibility. Prefer aggregates for operator dashboards, and move person-level queries to a
backend when those rows should not reach the browser.
Discovering queryTypes at runtime: describe()
import type { PtAppDataCatalog, PtAppQueryTypeDoc } from '@ptengine/app-sdk';
const catalog: PtAppDataCatalog = await window.PtApp.data.describe();
// catalog.queryTypes — the currently open queryType names, e.g. ['traffic_insight', 'event_insight', ...]
// catalog.schema — keyed by queryType; each value is a PtAppQueryTypeDoc:
// type the queryType itself (same as the key)
// summary one-line purpose
// description detail (when to use / not use, units, edge cases)
// requiredScope the scope to declare in manifest.json's scopes
// paramsSchema JSON Schema (draft-07) for params
// Get one queryType's param schema — typed, no assertion needed
const eventInsight: PtAppQueryTypeDoc | undefined = catalog.schema['event_insight'];
const eventInsightParamsSchema = eventInsight?.paramsSchema;
// Build a capability-probing UI: list what the platform supports right now
for (const name of catalog.queryTypes) {
console.log(name, '—', catalog.schema[name]?.summary);
}This is the same shape as data-query.schema.json (describe() and the generated docs share a
source), so you can discover which queryTypes the platform has opened — and their param schemas
— without upgrading this package. Useful during development and for user-facing capability
probes. Note that describe() is currently front-end only; the backend's ctx.pt.describe()
throws 501, so describe from the front end and use the validated params in the backend.
Result truncation
A single query returns at most 5000 rows. When truncation happens,
metadata.truncated === true and metadata.totalRowCount carries the real pre-truncation row
count; when nothing was truncated both fields are absent (do not write checks like
metadata.truncated === false). rowCount always equals this response's rows.length (5000
after truncation, not the grand total), so never use it as a denominator. If
metadata.warnings is present, it changes how the numbers should be read — surface it instead
of dropping it.
Reading assets (PtApp.asset)
Declare asset:read and the app can browse the product's Drive asset tree as the current
user — host-mediated like PtApp.data, so it sees exactly what that user can see in the
product, nothing more.
import type { PtAppDriveNodeMeta } from '@ptengine/app-sdk';
// 1. List the root folder (omit folderId = root)
const page = await window.PtApp.asset.list({ size: 50 });
// page = { items: PtAppDriveNodeMeta[], nextCursor: string | null, hasMore: boolean }
// 2. Open a Studio knowledge asset found there
const studio = page.items.find((n: PtAppDriveNodeMeta) => n.type === 'studio');
if (studio) {
const detail = await window.PtApp.asset.get({ type: 'studio', id: studio.id, include: ['sourceText'] });
// detail is a union discriminated by `type`; for 'studio' it carries content and sources[]
}
// 3. Top-level nodes other people shared with me (unreachable from the root)
const shared = await window.PtApp.asset.sharedWithMe();Following a shortcut: its targetType is string | null (not a union), so narrow it to one of
the four gettable types before calling get:
import type { PtAppAssetGetInput, PtAppDriveNodeMeta } from '@ptengine/app-sdk';
const GETTABLE = ['folder', 'page', 'group', 'studio'] as const;
const isGettable = (t: string | null | undefined): t is PtAppAssetGetInput['type'] =>
(GETTABLE as readonly (string | null | undefined)[]).includes(t);
async function openShortcut(node: PtAppDriveNodeMeta) {
if (node.type !== 'shortcut' || node.targetDeleted || !node.targetReadable) return null;
if (!isGettable(node.targetType) || !node.targetId) return null; // image / font / … : the node is all there is
return window.PtApp.asset.get({ type: node.targetType, id: node.targetId });
}- Node types:
list/sharedWithMereturnfolderpagegroupimagecomponentfontstudioshortcut;PtAppDriveNodeMetais a union you narrow ontype. Filter withlist({ types: [...] }), search withkeyword, and usescope: 'subtree'to go below direct children. - Pagination is cursor-based: pass the previous page's
nextCursorascursor. TrusthasMore, not the page length — a short page (fewer thansizeitems) withhasMore: trueis normal: the platform's permission scan has a per-page budget, and when it runs out the page is cut early. getonly takesfolder/page/group/studio— the type is narrowed at compile time, soget({ type: 'image', … })does not type-check. Everything there is to know about animage,font,componentorshortcutis already on itslistnode. Called anyway (e.g. from untyped JS), the platform answersASSET_TYPE_UNSUPPORTED— the same code as alist({ types })filter naming a type that is not open.- Error codes pass through verbatim (
err.code). The ones the platform documents (asset-read.schema.json→errorCodes):ASSET_TYPE_UNSUPPORTED(above),INVALID_INPUT(bad input, e.g.sizeout of 1..100 or an unknownincludevalue),NOT_FOUND(the asset does not exist),SCOPE_DENIED(asset:readnot declared / not granted),TOO_MANY_CONCURRENT_QUERIES(throttled — back off and retry),IAM_UNAVAILABLE(the platform's permission service could not be reached — retry later),CAPABILITY_UNKNOWN(the platform backend is older than this SDK and does not know the method yet). Also expect 403FORBIDDEN: the asset exists but the current user's permissions deny it — the permission service's own code, passed through verbatim and not one of the seven above. Group details (get({ type: 'group' })) rely on the permission service filtering member pages by the user's access (iam §6 #1); that fix ships in the same batch as this capability. This list is not exhaustive: other upstream codes also pass through verbatim, so always keep a generic fallback branch. A call can also fail earlier, at the bridge (e.g. the host has not allowed these methods yet) — those are the bridge's ownPT_*errors. - Like
data.query, asset calls have no client-side timeout — and they share the host's queue withdata.query(4 in flight, 50 queued). A route change (includingnav.syncRoute) cancels queued calls withQUERY_CANCELLED; a full queue rejects withQUERY_QUEUE_FULL. When walking a tree recursively keep 2–3 calls in flight; do notPromise.alla whole level. - Capability detection: until the host (and the ptengine-x backend) has been upgraded to
app-sdk ≥2.8.0,
window.PtApp.assetisundefined. Guard withif (PtApp.asset) …instead of assuming it exists. (The type declaresassetas required because the runtime client is shipped by the host at the same version as the host itself; the runtime guard only matters during the window where the host has not been upgraded yet.)
Node / detail shapes, the error-code list and which types are open live in two files shipped in this
package, generated from the backend's zod (same source as the TS types) so they cannot drift:
asset-read.llms.txt (human/LLM readable — read it before writing asset code) and
asset-read.schema.json (JSON Schema).
Which id is which. The write calls (create / update / uploadSource, backend only) call the Studio entity's id assetId. On the read side that same value is the node's id — pass it straight to get({ type: 'studio', id }), and match it against node.id when looking for what you just wrote. The assetId field on a read node is a different id: the permission system's (pacl) asset id. Never compare a written assetId with a node's assetId.
Searching is per folder. keyword matches names inside one folder: pass its folderId; add scope: 'subtree' to also match inside its sub-folders. Without folderId, cross-tree search is not supported: the result depends on the caller's permissions — a user with full visibility may get matches from deep in the tree, while a user with restricted permissions only gets matches among their visible top-level entries. The same call can find an asset for one user and nothing for another, so never rely on it. The product's own Drive search likewise searches the open folder.
textContent exists only for text sources — text pasted in the product UI (sources[].type === 'text'). Files added with uploadSource (or uploaded in the UI) are file / image sources: their textContent is always null, and their content is at url.
