@blotout/qinto-sdk-app
v0.1.12
Published
Qinto app SDK: the authoring surfaces for Qinto platform apps — defineApp (server) and defineBrowserApp (browser) plus their hook, context, and action types. Zero runtime dependencies.
Readme
@blotout/qinto-sdk-app
The authoring surface for Qinto platform apps: defineApp for the server, defineBrowserApp for the page,
createAppUi for the settings panel, and the types each one hands your handlers. Zero runtime dependencies.
Why we built it
A Qinto app is code that runs on the platform, inside the site's own edge. A site sends each visitor action once, and every app installed on it receives that action server-side: a transform app rewrites, enriches, or drops the event before destinations see it, and a destination app forwards it onward. The site's own tracking code never changes when a site adds your app.
That is a very different place to write code than either end you are used to:
- It is not a server you host. There is no origin to run, scale, or pay for. You declare what your app needs in
qinto.app.jsonc— a KV namespace, a D1 database, a workflow, an HTTP endpoint, a cron schedule — and the platform builds the Worker, provisions the bindings, and dispatches into your handlers. - It is not a script in the customer's page. The event reaches you one hop from where it was recorded, with the visitor already resolved to an identity, the site's consent already applied, and the site's settings already filled in. You never ask a customer to paste anything.
- Everything your app can do is declared. Capabilities, captured attribution names, published topics, and settings a panel may write are what a site manager reads and accepts before installing — and again whenever a later version widens them.
This package is the contract between your handlers and that runtime. It exists as a package, rather than as a shape you guess at, for three reasons:
- The types are the documentation. What a
transformhook may return, what rides onctxin a scheduled run versus a visitor action, which context is a value and which is a call — all of it is on hover. - It ships
app.schema.json. Point your manifest's$schemaat it and your editor completes and validatesqinto.app.jsoncoffline, against the exact SDK version you installed. - The definitions are checked before you publish.
defineApprecords which hooks you actually declared, and the CLI compares that against the manifest atpublishand atdev— so a hook the manifest promises and the bundle does not export fails on your machine, not at dispatch on a customer's site.
Install
npm i @blotout/qinto-sdk-app
npm i -g @blotout/qinto-cliUsually you do not: qinto app create my-app scaffolds a manifest, a server entry, and this dependency for you, and
offers to register the app under your organization.
What you import
| Entry | Exports | Runs |
| --- | --- | --- |
| @blotout/qinto-sdk-app/server | defineApp, defineWorkflow, runAi, runAiBytes | In your app's Worker, on the platform. |
| @blotout/qinto-sdk-app/browser | defineBrowserApp | In the visitor's page, inside the site's tag. |
| @blotout/qinto-sdk-app/ui | createAppUi | In your settings panel, framed by the Qinto platform. |
The package also ships app.schema.json for your manifest's $schema.
The server app
import { defineApp } from '@blotout/qinto-sdk-app/server'
type Env = { PURCHASES: D1Database }
export default defineApp<Env>({
transform: {
track: async (action, ctx) => {
if (action.payload.event === 'Blocked Event') {
return ctx.drop()
}
return ctx.replace({ ...action.payload, properties: { ...action.payload.properties, siteTag: ctx.variables.SITE_TAG } })
}
},
onEvent: {
track: async (action, ctx) => {
const response = await fetch(String(ctx.variables.ENDPOINT_URL), {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${ctx.secrets.API_KEY}` },
body: JSON.stringify({ event: action.payload.event, properties: action.payload.properties, userId: action.userId })
})
if (!response.ok) {
ctx.observability.error('delivery refused', undefined, { status: response.status })
return
}
ctx.observability.log('track delivered', { event: action.payload.event })
}
}
})defineApp takes one object. Every key below is optional, and an unknown key is refused rather than ignored.
Every visitor action includes receivedAt; action.context additionally carries occurredAt,
source/page/network data, and a browser session as { id, isNew, startedAt } when available.
startedAt is fixed when Qinto creates the session id, so destinations can make stable session-cutoff
decisions.
| Key | What it is for |
| --- | --- |
| transform | track and identify hooks that run before destinations, and return ctx.pass(), ctx.drop(), or ctx.replace(payload). |
| onEvent | track, identify, and consent hooks that run after the transform chain — where a destination does its work. |
| onConnect | Runs once when a visitor's page connects, before any action. |
| onLifecycle | Advisory notices that the install changed: installed, configurationChanged, enabled, disabled, versionChanged, uninstalled. They observe; they never approve. |
| schedules | Cron handlers, keyed by the ids the manifest declares. |
| endpoints | HTTP handlers served at https://<site>/app/<slug><path>. Webhooks have a 10-second total deadline; API handlers must return headers within 10 seconds and may then stream for up to five minutes, with a 150-second idle deadline. |
| rpc | Functions other apps on the same site may call. |
| ui | The server side of your settings panel, reached at <siteOrigin>/apps/<slug>/ui<path>. |
| workflows | Durable runs built with defineWorkflow, keyed by the class name the manifest declares. |
| migrations | Resumable backfill handlers named by a D1 migration. |
What rides on ctx
| | |
| --- | --- |
| ctx.env | The bindings your manifest's resources declared — typed by the Env you pass to defineApp<Env>. A pipeline binding's send is retried for you on a transient Cloudflare failure; the manifest reference says what that means for a sink. |
| ctx.variables, ctx.secrets | This install's settings, filled in by the site manager. |
| ctx.appVariables, ctx.appSecrets | Your own settings, one value for every site that installs the app. |
| ctx.install, ctx.site | Which install this invocation is for, and which site it is on. |
| ctx.geo | The current request's complete Cloudflare cf object when the app declares the geo data capability. On an endpoint this describes the endpoint caller, not a stored visitor. |
| ctx.user | The visitor: anonId, globalId, stored properties, and geo when granted. |
| ctx.consent | { enabled, categories, apps }, when consentState is granted. Your manifest's category is already enforced before you are called. |
| ctx.emit | Emit a track of your own back into the pipeline. Your app never receives its own emission. |
| ctx.push | Send a message down the visitor's live connection, to your browser code. |
| ctx.attribution | Read the click ids and cookies your manifest captures for this visitor, and write derived ones. |
| ctx.facts | Read stable, paginated retained visitor facts with list(filters) and opaque continuation cursors; requires the facts data capability. |
| ctx.context | Per-visitor state your app keeps: a private level only you read, and a shared level every app on the site can. |
| ctx.apps | Call another installed app's exposed rpc functions through the site's broker. |
| ctx.startWorkflow | Start one of your declared workflows, carrying this install — and the visitor — into the run. |
| ctx.observability | log, error, and span. Everything you record shows up in the site's real-time console, next to the platform's own spans; a plain fetch is traced for you. |
A scheduled run, MCP tool, endpoint, and workflow step carry a narrower context — they have no visitor, so there is no
user, consent, or emit there, and push names its subject instead of inheriting one. An endpoint still carries
the current caller's cf object in ctx.geo when the app declares the geo data capability. Scheduled runs and MCP
tools can query a catalog-enabled R2 resource with ctx.query.catalog(resourceIdentifier, sql) and an Analytics Engine
dataset with ctx.query.analytics(resourceIdentifier, sql) — name the dataset by its resource identifier in FROM;
Qinto keeps the physical bucket, the dataset name, and the credential out of the app worker. The types say which
capability is available where.
Durable work
import { defineApp, defineWorkflow } from '@blotout/qinto-sdk-app/server'
export default defineApp<Env>({
endpoints: {
'/webhook': async (request, ctx) => {
await ctx.startWorkflow(ctx.env.ORDER_SYNC, { orderId: '123' }, { id: 'order-123' })
return new Response('queued', { status: 202 })
}
},
workflows: {
OrderSync: defineWorkflow<Env, { orderId: string }>(async (event, step, ctx) => {
const order = await step.do('fetch', async () => {
return fetchOrder(event.payload.orderId, ctx.secrets.API_KEY)
})
await step.sleep('settle', '1 hour')
await step.do('notify', async () => {
await ctx.push({ topic: 'order:synced', data: order })
})
})
}
})Cloudflare owns the retries, the sleeps, and the per-step persistence, so a destination does not hand-roll its own
retry loop. Passing options.id names the instance, which is how a retried vendor webhook avoids starting a second run.
Workers AI
Qinto hands out no AI binding: a binding would spend the hosting account's inference allowance with no way to
attribute or cap it. runAi calls Cloudflare's /ai/run endpoint with a token you supply, so the run bills
your account.
import { runAi } from '@blotout/qinto-sdk-app/server'
const { response } = await runAi<{ response: string }>({
accountId: ctx.appVariables.CF_ACCOUNT_ID,
apiToken: ctx.appSecrets.CF_AI_TOKEN,
model: '@cf/meta/llama-3.1-8b-instruct',
input: { prompt: 'Summarise this basket' }
})The token needs Account → Workers AI → Read; one holding only an AI Gateway permission is refused, and runAi
says so rather than passing on Cloudflare's bare Authentication error. It also unwraps the response envelope and
raises the refusals Cloudflare returns under HTTP 200.
Some models answer bytes rather than an envelope — @cf/myshell-ai/melotts documents both application/json and a
raw audio/mpeg body, and picks per run. runAiBytes reads those, returning the content type alongside the bytes:
const { contentType, bytes } = await runAiBytes({
accountId: ctx.appVariables.CF_ACCOUNT_ID,
apiToken: ctx.appSecrets.CF_AI_TOKEN,
model: '@cf/myshell-ai/melotts',
input: { prompt: 'Your order has shipped', lang: 'en' }
})The two read the same run and disagree only about the body, so each says which one the answer wanted: runAi raises
ai_invalid_response naming the content type it got, and runAiBytes raises ai_json_response when the run
answered an envelope after all. Refusals map to the same errors from either.
Both read one buffered answer, so both refuse input.stream: true rather than sending a run whose server-sent
events neither can parse — the refusal comes before the request, so a stream you meant to consume is never billed.
Call /ai/run yourself when you want to stream.
Cloudflare refuses a @cf/ model that names no gateway, so runAi sends default for one — the single gateway id
Cloudflare creates on first use, with logging on, caching off, and Standard billing. Pass gatewayId to route
through one of your own instead, which is where spend limits and per-model settings live; any id other than
default must exist before you use it. A third-party model (openai/…, anthropic/…) needs no gateway and gets
no header.
Declare both values in the manifest's appVariables so they are yours and set once, not asked of every site that
installs the app — the token with qinto app secret set, the account id with qinto app var set.
For chat completions, point the OpenAI SDK at the account's /ai/v1/chat/completions instead; that path is
OpenAI-compatible and needs nothing from us. The gateway rule still applies there, so a @cf/ model needs the
header set on the client:
const client = new OpenAI({
apiKey: ctx.appSecrets.CF_AI_TOKEN,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${ctx.appVariables.CF_ACCOUNT_ID}/ai/v1`,
defaultHeaders: { 'cf-aig-gateway-id': 'default' }
})The browser app
An app may also ship code that runs in the page, inside the site's tag. The factory runs once per active install, and only for a visitor whose consent covers your app.
import { defineBrowserApp } from '@blotout/qinto-sdk-app/browser'
export default defineBrowserApp((app) => {
app.onEvent((event) => {
return { observedAt: Date.now(), siteTag: app.variables.SITE_TAG }
})
app.onPush((message) => {
console.info('server push', message.from, message.topic, message.data)
})
})| | |
| --- | --- |
| app.variables, app.install | This install's browser-exposed plain settings, and which install it is. Secrets never reach the page. |
| app.getSession | The current anonId and linked globalId. Apps declaring the geo data capability also receive the server-observed ip and geo: { country, region, state, isEU }; otherwise both location fields are null. |
| app.onEvent | Every action on the page. What you return reaches your own server hook for that same action as action.browser, when the manifest allows it — treat it as untrusted page input. |
| app.onConsent | The visitor's consent, on connect and on every change. |
| app.onPush | Messages your server code sent with ctx.push. A page that was closed missed them; pushes are never replayed. |
| app.onSharedContext | What every contributing app knows about this visitor, where both sides declared the capability. |
| app.track | Send an event of your own. |
The settings panel
A panel is a static page the Qinto platform frames when a site manager configures your app. createAppUi is the bridge
to the page around it.
import { createAppUi } from '@blotout/qinto-sdk-app/ui'
const ui = createAppUi()
const context = await ui.getContext()
const response = await ui.fetch('/analytics', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ installId: context.appInstallId })
})
const events = await ui.query.catalog(
'lake',
'SELECT event_name, event_timestamp FROM lake.events ORDER BY event_timestamp DESC LIMIT 100'
)
const usage = await ui.query.analytics(
'events',
'SELECT blob1 AS name, SUM(_sample_interval) AS count FROM events WHERE timestamp > NOW() - INTERVAL \'1\' DAY GROUP BY name'
)| | |
| --- | --- |
| getContext() | The team, the site, your app and version, the install the panel was opened for, every live install with its plain variables, and the theme. |
| getToken() | The current panel token, renewed before it expires. |
| fetch(path, init?) | Calls your own ui server handler with that token attached, and returns the raw Response. Parse it, and decide yourself what a 429 or a 5xx means. |
| query.catalog(resourceIdentifier, sql) | Runs R2 SQL against a catalog-enabled R2 resource in the version serving this panel. The same method is available on scheduled-run and MCP-tool contexts. Qinto keeps the physical bucket and host credential server-side and returns { rows, meta }. |
| query.analytics(resourceIdentifier, sql) | Runs one single-table SELECT against an analytics_engine resource in the version serving this panel; name the dataset by its resource identifier in FROM. Also on scheduled-run and MCP-tool contexts. Qinto substitutes the real dataset name server-side and returns { rows }; a never-written dataset returns none. |
The platform gives the panel the whole area under the site's header and nothing else — size your page to it and let it scroll inside, the way any page in a window does.
A link to another site opens a new tab: give it target="_blank", or call window.open. The tab is a normal one,
outside the sandbox. The frame itself stays on your panel's own origin — the platform refuses any other origin inside
it, and it never leaves the page on the panel's behalf except through connect.
Your panel runs in the viewer's browser, so it never receives a secret. The install id it posts is input, not
authority — resolve it against ctx.installs in the server handler, which the platform built from the site's own
registry.
The manifest
qinto.app.jsonc is the app: it names it, says where it plugs into the pipeline, and declares everything it may reach.
Point $schema at the file this package ships and your editor does the rest:
{
"$schema": "./node_modules/@blotout/qinto-sdk-app/app.schema.json",
"slug": "my-app",
"name": "My App",
"flows": ["destination"],
"consent": { "category": "analytics" },
"legal": { "termsUrl": "https://example.com/terms", "privacyUrl": "https://example.com/privacy" },
"server": {
"entry": "src/server.ts",
"hooks": { "transform": [], "onEvent": ["track"], "onConnect": false }
}
}The manifest is authoritative: server.hooks must match the hooks your entry exports, every declared schedule,
workflow class, and backfill handler must exist, and a ui block needs a ui handler. The CLI checks all of it before
anything is uploaded.
The loop
qinto app create my-app # scaffold, install, register
qinto app dev # run it against a local Qinto — real platform bundles, no account, no login
qinto app publish # build, upload, and publish an immutable version
qinto app publish --site example.com
qinto app status my-app --site example.comqinto app dev boots the released site worker and app runtime in workerd, with a console, a demo site, and a live
trace of every request — so the transform chain, the consent gate, and the dispatch into your hooks are the real ones.
Documentation
The manifest reference, the app limits, the standard events, and the CLI are at docs.qinto.io.
