@blotout/qinto-cli
v0.1.17
Published
Qinto CLI: authenticate against the Qinto platform and operate it from the terminal or from an agent.
Readme
@blotout/qinto-cli
The Qinto platform CLI. Authenticate against the Qinto platform and operate it from the terminal — or from an AI agent.
Install
npm i -g @blotout/qinto-cliNeeds Node.js 24 or newer. The command is qinto.
Stay up to date
qinto updateupdate asks npm for the newest published version and, when that is newer than the one running, installs it with the package
manager that installed the CLI — npm, pnpm, yarn, or bun, each detected from the install path. An npx run and a source checkout
have nothing to update, and the command says so instead of installing anything.
Every other command also checks for a new version, at most once a day, and prints a yellow notice on stderr when one exists. The
notice never appears in machine mode (--json, --no-input, or a non-terminal stderr), so piped and agent-driven output stays
clean. NO_COLOR drops the colour; QINTO_NO_UPDATE_CHECK=1 turns the check off altogether.
Log in
qinto loginlogin opens your browser on the Qinto consent screen. Authorize one, several, or all of your current teams; each team receives
the strongest access your role allows there. By default the CLI requests full access (every scope the platform supports, plus
offline_access for silent renewal and profile for your account identity); pass --scopes to narrow the request.
qinto whoami # who am I, with my teams, organizations, and scopes
qinto whoami --json # the same identity as JSON
qinto env # choose which Qinto every command talks to
qinto team # list authorized teams and mark the active one
qinto team active # choose the local default team
qinto team active <teamId>
qinto logout # revoke the session on the server, then clear local credentialsThe active team is a local default, not an authorization boundary. Use --team <teamId> on a command for a one-off override.
qinto login --team <teamId> preselects that team during consent and prefers it as the active team afterward; use
--reauthorize to force the consent screen. A plain login preserves the active team while it remains authorized. The CLI
revokes the login it replaces; if that revocation fails, it is remembered and retried on the next login or logout.
Onboard a domain
qinto domain onboard example.com
qinto domain dns shop.example.comdomain onboard guides a human through team confirmation, managed or self-hosted infrastructure, advanced placement settings,
DNS records, optional email sharing, delivery, deployment, and the final integration snippet. Switching teams in this flow also
updates the saved active team. DNS can remain pending without making onboarding fail; resume with the secret-free command printed
by the CLI, or force a fresh check later with qinto domain dns [siteId-or-hostname].
Every prompt has a flag, so a full onboarding can be supplied up front. qinto describe domain onboard prints the same list as a
machine-readable spec.
| Flag | What it does |
| --- | --- |
| --team <teamId> | Use this authorized team without changing the active team. |
| --mode <mode> | Choose the infrastructure: managed or self. |
| --name <name> | Site display name. |
| --subdomain <subdomain> | Site subdomain; also matched against existing sites to detect a resumable one. |
| --jurisdiction <jurisdiction> | Choose the EU (eu) or US (us) data jurisdiction. |
| --location-hint <locationHint> | Place site state near a Cloudflare region: wnam, enam, weur, eeur, apac, or oc. |
| --delivery <delivery> | Serve from the customer domain (domain) or a development URL (development-url), even while DNS is pending. |
| --site <siteId> | Resume onboarding an existing site instead of creating one. |
| --share-dns <emails> | Email DNS instructions to 1–10 comma-separated recipients. |
| --share-submission-id <submissionId> | UUID used to retry DNS sharing idempotently. |
| --host-id <hostId> | Use an existing self-host (requires --mode self). |
| --host-name <name> | Display name for a new self-host (requires --mode self). |
| --cloudflare-account-id <accountId> | Cloudflare account ID for a new self-host (requires --mode self). |
| --host-zone <zone> | Cloudflare zone to use for the self-host. |
| --workers-for-platforms | Enable Workers for Platforms on a new self-host. |
| --no-input | Disable all interactive prompts. |
| --json | Print a structured onboarding state as JSON. |
Re-running onboarding after an abort tries not to create a duplicate site. The CLI canonicalizes the given domain the way the
server does (https://www.example.com/ and example.com name the same site) and looks for existing sites on it: interactively
it offers to resume one or create a new site, and in machine mode it fails with the candidate ids so a script passes --site
<siteId> to resume or --subdomain to force a new site. Detection is best-effort — it needs team:read to list the team's
sites, so a credential that only carries domain:write skips the check and creates a new site on every run. Resume by id
instead: right after a site is created the CLI prints its id and a resume command to stderr, and if onboarding fails after that
the error names the site and the same command. A resume command is qinto domain onboard --site <siteId> --team <teamId>,
followed by whatever names the environment it ran against (--env sandbox, or --api-url <origin>, and nothing for
production); when it is produced for a machine it also carries --no-input --json.
For automation or an AI agent, use --json, --no-input, or a non-interactive terminal. Supply every conditional input up front:
qinto domain onboard example.com --mode managed --team <teamId> --no-input --json
qinto domain onboard example.com \
--mode self \
--host-name production \
--cloudflare-account-id <accountId> \
--workers-for-platforms \
--no-input \
--json
qinto domain dns <siteId> --team <teamId> --jsonSet QINTO_CLOUDFLARE_API_TOKEN when connecting a new self-host non-interactively. The token has no command-line flag and is never
included in JSON, output, or resume commands. Structured onboarding results use host_setup_pending, dns_pending, or complete;
all obtained pending states exit 0, operational failures exit 1, and invalid or incomplete invocations exit 2.
Build an app
An app is code you write that runs on the Qinto platform: a transform rewrites, enriches, or drops events before destinations see them, and a destination receives events after transforms ran and forwards them onward. An app belongs to an organization and is published as immutable versions; a team then installs a version on one of its sites.
The whole loop is a handful of commands:
qinto app create my-app # scaffold, install, and offer to register
qinto app dev # run it against a local Qinto
qinto app publish # build, upload, and publish a version
qinto app publish --site example.com # …and install it on a site
qinto app status my-app --site example.com # desired vs deployed stateGet a developer organization
A team is not an organization. A team owns sites and installs apps; an organization owns the apps you write. Membership in a
team — even as its owner — gives you nothing in any organization, and switching the active team does not change that. Until your
account belongs to an organization, qinto app register refuses with one of:
This credential has no organizations. Log in with `qinto login` first.
Organization <name> is not available to this credential.Neither is fixed by logging in again. qinto whoami says which case you are in — it prints an Organizations: block listing each
organization and your role in it, and prints no such block when you belong to none.
The CLI cannot create an organization; the Qinto platform does. Open it in a browser and click Developer in the sidebar, or go straight to the Developer area:
| Environment | Developer area |
| --- | --- |
| production | https://portal.qinto.io/developer |
| sandbox | https://portal.qinto.tech/developer |
Opening that page is the whole step. The first visit creates a personal organization named after your email address and makes you its owner — nothing is approved, granted, or provisioned by anyone else. Use the organization switcher at the top of the Developer sidebar to rename it, or Create organization to add another (one per customer or product, say).
To publish under an organization that already exists — a company's, rather than your own — one of its owners invites you under Developer → Organization members and you accept the emailed link. An organization cannot be joined by naming its id.
Back in the terminal the organization appears immediately: the CLI reads it from the API on every call, so no new login is
needed. Pass it by id or name with --org.
qinto whoami # confirm the Organizations block now lists it
qinto app register ./my-app --org "Acme Inc."If whoami still lists no organization when you know you belong to one, the login is too narrow rather than the organization
missing: your account identity needs the profile scope and every qinto app command needs developer. A plain qinto login
requests both, a login narrowed with --scopes may not, and whoami names it — Authenticated with an OAuth token without the
profile scope. Run qinto login --reauthorize and grant them.
Scaffold — qinto app create [dir]
Asks for the app name, proposes a slug derived from it that you confirm or replace, and asks which template to start from. It
then writes the scaffold, installs its dependencies, and offers to register the app under one of your organizations so you do
not have to call qinto app register yourself; answer no, or pass --no-register, to scaffold only. In machine mode nothing is
registered unless you pass --register.
Two templates:
| Template | What you get |
| --- | --- |
| minimal | qinto.app.jsonc, package.json, tsconfig.json, and a src/server.ts with one track hook. |
| full | The same, plus a transform hook, a src/browser.ts bundle, a D1 database that records every Purchase (order id, value, and the SHA-256 of the visitor's email), three install variables, and the Astro UI panel under ui/, wired to the platform through @blotout/qinto-sdk-app/ui. |
The full app forwards events to an endpoint the site manager supplies. Both that endpoint and its API key are required
variables, so the install stays inactive — and the app sends nothing anywhere — until they are filled in.
The install runs npm install in the app directory — and in ui/ when a panel was scaffolded. A failed install keeps the
scaffold and names the command to rerun, but exits 1, so a script does not publish an app with no dependencies.
--no-install skips the install and exits 0.
The panel imports @blotout/qinto-sdk-app/ui and resolves it from the app's own dependency one directory up, so ui/package.json
declares only its build tooling.
| Flag | What it does |
| --- | --- |
| --name <name> | App display name. |
| --slug <slug> | Immutable app slug: 3–30 lowercase letters, digits, and hyphens, starting with a letter. Derived from the name when omitted. |
| --template <template> | minimal or full. Prompted when omitted; minimal without prompts. |
| --ui | Also scaffold the Astro UI panel under ui/ and wire ui into the manifest. The full template always has one. |
| --no-install | Skip installing the scaffold dependencies. |
| --register / --no-register | Register the app after scaffolding, or skip the offer entirely. |
| --org <organization> | Owning organization id or name when registering (prompted when ambiguous). |
| --no-input | Disable all interactive prompts. |
| --json | Print the scaffolded files — and the registered app, when one was created — as JSON. |
qinto app add ui [dir] adds the same UI panel to an app that was scaffolded without one, and installs it.
Register — qinto app register [dir]
Registers the slug under an organization — see Get a developer organization when you have none.
create --register calls it for you; run it directly to register an app you scaffolded earlier or wrote by hand. The slug is
immutable and unique inside the organization.
The slug, name, and description come from qinto.app.jsonc — the one in dir, or the one in the current directory when you pass
no dir. Pass --slug, --name, and --description only for an app that has no manifest yet; alongside a manifest they are
refused, because a slug that differs from the manifest is one qinto app publish then rejects.
qinto app register ./my-app # slug, name, and description from its manifest
qinto app register --org Acme # the manifest in the current directory
qinto app register --slug my-app --name "My App" --description "What it does." # no manifest
qinto app list # every app across your organizations
qinto app list --jsonDevelop locally — qinto app dev [dir]
dev runs your app against a local Qinto. It boots the released site worker and app runtime — the same bundles a deploy
uploads — inside workerd, so the transform chain, the consent gate, the fan-out envelope, the app runtime and /q.js assembly are
the real ones rather than a simulation. Nothing is uploaded, no Cloudflare account is involved, and no login is needed: the CLI
ships the platform bundles and prints the version it is running.
qinto app dev # from inside the app directory
qinto app dev ./apps/my-app
qinto app dev ./apps # a folder of app directories — pick which to mountA directory holding a qinto.app.jsonc runs that one app. A directory that does not is scanned one level down, and every
subfolder holding a manifest is offered; everything is ticked, so pressing Enter mounts them all. --apps my-app,crm skips the
question, and --no-input mounts everything found.
2 apps — local Qinto on platform 0.1.0
Console http://127.0.0.1:4300
Demo site http://127.0.0.1:4301
Site worker http://127.0.0.1:4310
Dev state ~/.qinto/dev/8f3c1a2b4d5e6f70
Site token qwat_dev_…
My App (my-app) — installed and active on the seeded site
panel http://127.0.0.1:4302/dev-my-app/0.0.0-dev/
CRM (crm) — installed, but no install is active until you set API_KEY in the consoleApps mounted together share one site, which is what makes everything that crosses apps testable: one event fans out to every
app's hooks, ctx.context.shared is readable across them, a site-wide ctx.push reaches them all, and ctx.apps.call really
goes caller → the site worker's broker → callee, each app in its own worker. Calls to a public function work as soon as both
apps are mounted; a private one is refused with 403 forbidden until you approve it under App-to-app approvals on the
callee's page, exactly as a site manager does on a deployed site.
An app you call but do not have is mocked for you. When a mounted app's rpc.calls names a slug nothing mounts, dev
scaffolds mocks/<slug>.ts inside the calling app — one exported function per function it exposes — and mounts it under that
slug. ctx.apps.call('resend', 'send', args) then takes the real path: caller → broker → the mock's own worker, with no if in
your code and nothing to change when the real app arrives beside you.
If you are logged in, the mock is typed: dev fetches the app's published rpc contract, writes it next to the stub as
mocks/<slug>.rpc.json, generates mocks/<slug>.types.ts from it, and the platform validates every call and result against it —
so a mock that lies about its shape fails locally the way the real app would. Commit all three files; the sidecar keeps the mock
typed offline and for anyone else who checks the app out. Without a contract (not logged in, or the app is unpublished) the stub
takes and returns unknown and the terminal says why.
An app folder added or removed while dev is running is not picked up — it says so and asks for a restart. A save inside a
mounted app rebuilds that app and every mock alongside it.
Declared server.schedules fire on their own while dev runs — at the cron's minute, in UTC, with scheduledTime set to that
instant, exactly as the deployed sweep dispatches them. Each schedule on the app's console page also has a Run now button,
so you never wait for the window.
Three surfaces, one per side of the product:
- The console is the platform's side. A team and the site
dev.localhostare seeded for you; every mounted app is listed under Site → Apps, where you install it, turn it on and off, manage its installs, and fill in the variables the manifest declares. It renders the same form the Qinto platform does — text, toggle, and write-only secrets — and an install staysinactive, receiving nothing, until everyrequiredvariable is filled in. Adding an install is offered only for an app whose manifest setsinstall.multiple, and the last install is never removable — uninstall the app instead. Analytics → ID graph opens the visitor's anon, alias and person objects exactly as the platform's page does: paste an anon id, an email or a person id and read its keys and tables as your app writes into them. - The demo site is the customer's page. It loads
q.jsfrom the local site worker and drives every method the tag exposes —track,identify,consent,alias,getSession— then shows which app bundlesq.jscarried and which installs activated. Its Visitor panel shows the browser's anon id, the one to paste into the ID graph page, and the person id onceidentifylinks one. Its Events panel has a button for each of the eleven standard events in the standard events reference: selecting one loads its documented payload into an editable box, and Track it sends whatever is in it, so you can fire the real shape or your own. Every event reaches your hooks; only one carrying a numericvalueand a non-emptyorderId—Purchase, among the standard ones — is also recorded as a conversion in the identity graph. - The terminal streams the trace of every request, your app's own
ctx.observabilitylines included: they arrive in the same trace as the site worker's spans, exactly as they do in the Qinto platform's real-time logs.
dev builds the app the way publish does — the same esbuild bundles, and the same cross-check that event/lifecycle hooks,
backfill handlers, and workflows match the manifest — so a mismatch fails here instead of at dispatch. A d1 resource's phased
migrations use the same statement ledger and backfill checkpoints as a deployed app: rerunning dev applies only what is new,
and an entry changed after it was applied is refused by id and checksum.
Everything the session persists — each app's installs, its variables and secrets, and every Durable Object, D1, KV and R2 the
workers wrote — lives in ~/.qinto/dev/<directory>/, keyed on the directory you pointed dev at and outside your app
directories, so a restart resumes where you left off and the plaintext secrets file can never be committed. Each app's resources
are namespaced by its slug, so two apps declaring a d1 called ledger get a database each. --reset discards all of it.
| Flag | What it does |
| --- | --- |
| --apps <slugs> | Comma-separated slugs to mount, instead of choosing them. |
| --console-port <port> | Port the console is served from (default 4300). |
| --demo-port <port> | Port the demo site is served from (default 4301). |
| --worker-port <port> | Port the local site worker listens on (default 4310). |
| --ui-port <port> | Port the app panels are served from (default 4302). |
| --reset | Discard this directory's local installs, variables, secrets and stored data first. |
| --tunnel | Expose the site worker on a fresh trycloudflare.com hostname, so a vendor webhook can reach its endpoints. |
| --tunnel-name <name> | Expose it through this named Cloudflare Tunnel instead, for a hostname that survives a restart. |
| --tunnel-hours <hours> | Close the tunnel after this many hours, instead of leaving it open until app dev stops. |
| --no-input | Disable all interactive prompts, mounting every app found. |
All four listeners bind 127.0.0.1 only.
Reaching your endpoints from outside — --tunnel. A vendor webhook cannot call 127.0.0.1, so --tunnel opens a
Cloudflare Tunnel to the site worker and prints the public origin; the console's Endpoints card then leads with the URL to
register with the vendor and keeps the loopback one beside it for curl.
Anyone who has this URL can:
· call your app’s ungated endpoints
· make your app code run against real vendor APIs, with your dev variables and secrets
· reach whatever the site worker passes through
Restrict it with Cloudflare Access on a named tunnel (--tunnel-name) if that matters here.
Opening a Cloudflare Tunnel to the site worker — this usually takes 20-40 seconds.
· downloading cloudflared 2026.8.3…
· starting cloudflared…
· connecting to the Cloudflare edge…
· publishing odd-mule-hits-yet.trycloudflare.com in DNS…
Site worker http://127.0.0.1:4310
Public site https://odd-mule-hits-yet.trycloudflare.com
! anyone holding this URL reaches your dev site worker
My App (my-app) — installed and active on the seeded site
POST https://odd-mule-hits-yet.trycloudflare.com/app/my-app/hookcloudflared is downloaded and cached on first use, unless you already have one on PATH or at CLOUDFLARED_PATH.
The tunnel closes when app dev does. It is a public door onto your machine, so quitting dev is what shuts it: there is no
separate clock, and a session left running overnight holds the door open until you stop it. Cloudflare places no limit of its own,
so a named tunnel will happily run for days.
--tunnel-hours <hours> closes it earlier. Give it a deadline and dev says how long is left every ten minutes; press a
then Enter to add an hour, as often as you like. When it closes, the public URL stops being advertised and the loopback one
carries on. Fractions work, so --tunnel-hours 0.5 is half an hour. Nothing caps the number.
For a run measured in days, use --tunnel-name. Cloudflare gives trycloudflare.com no uptime guarantee and caps it at 200
in-flight requests, so a quick tunnel is a debug aid rather than something to leave up over a weekend.
A hostname that survives a restart — --tunnel-name <name>. A quick tunnel is free and needs no account, but its hostname is
new on every run, so a vendor has to be told the new one each time. A named tunnel keeps one hostname. dev reads it from your
own Cloudflare account, never from anything Qinto holds.
Run qinto app dev --tunnel-name <name>. The first time, dev asks for the two things it needs and tells you how to make them:
A named tunnel is read from your own Cloudflare account, and qinto has no credentials for it yet.
1. Open https://dash.cloudflare.com/profile/api-tokens
2. Select Create Token, then Create Custom Token.
3. Under Permissions, add: Account > Cloudflare Tunnel > Edit
To let qinto create the tunnel for you, also add: Zone > Zone > Read and Zone > DNS > Edit
Nothing more: the token is saved on this machine.
(An account-owned token lists the first row as Cloudflare One Connector: cloudflared, or Argo Tunnel (Legacy).)
4. Under Account Resources, select the account that holds the tunnel; under Zone Resources, the zone its hostname is on.
5. Create the token and copy it — Cloudflare shows it once.
Your account ID is the last part of the dashboard URL:
https://dash.cloudflare.com/<account-id>They are saved in ~/.qinto/cloudflare.json, readable only by you, and every later run uses them without asking. They are
written only after Cloudflare accepts them, so a mistyped token is asked for again rather than stored and reported as a tunnel
fault. Delete that file to enter different ones. CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in the environment override the
saved pair and overwrite nothing — that is how CI, or a second account, runs. Set both or neither: one on its own is refused
rather than paired with a credential from somewhere else. --no-input, and a run with no terminal, say what to set instead of
prompting.
A tunnel that does not exist yet is created for you. When the account has no tunnel under that name, dev says so and asks
whether to create it. Say yes and it asks for the public hostname a vendor should call — a zone on that account or a name one
level below it, such as hooks.example.com; Universal SSL covers no deeper name, so a deeper one is refused before anything is
made — then makes the tunnel under the name you gave --tunnel-name, routes that hostname to
http://127.0.0.1:4310 (the port --worker-port sets), and adds the proxied CNAME record for it to your zone. The zone is found
from the hostname, so nothing is created when no zone on the account holds it; a failure after the tunnel exists — a DNS record
already on that name, or a token without the zone rows — removes the tunnel again, so a fixed token or a different hostname is a
clean retry. dev never deletes a DNS record: when the record's write got no readable answer, it names the record to check in the
dashboard instead. From then on the tunnel is found by name and nothing is asked. Say no and dev prints the dashboard steps
instead:
To create the tunnel "hooks" yourself:
1. Open https://one.dash.cloudflare.com/<account-id>/networks/tunnels and select Create a tunnel.
2. Choose Cloudflared and name it "hooks". Skip installing a connector — qinto runs cloudflared for you.
3. Under Public Hostnames, add the hostname a vendor should call, with service http://127.0.0.1:4310 (the port --worker-port sets).
Then run the command again.--no-input, and a run with no terminal, print the same steps rather than creating anything: making a tunnel and a DNS record
in your account is a question, and a run that promised no prompts cannot ask it.
dev looks the tunnel up by name and runs it on the token it fetched. cloudflared reads ingress rules top to bottom and stops at
the first match, so the rule that decides a hostname is often not the rule naming it — an earlier catch-all, wildcard, or
same-hostname rule takes the request first. A hostname is advertised only when every rule that can take it points at your worker
port, ending in one that carries no path. A rule with a path takes some requests and leaves the rest to the rules below it, and
that path is a regex dev cannot evaluate, so one pointing elsewhere is refused rather than guessed at — it may be the rule that
takes your /app/... URLs. Wildcards are never offered: https://*.example.com is not something a vendor can call, though it still
decides the hostnames it covers. Every advertised hostname is then checked in DNS, not just the first. A tunnel that routes nothing
usable is refused, and the message lists its rules in the order cloudflared reads them. A tunnel created locally
with cloudflared tunnel create has no configuration in the dashboard, so it lands in the same message — configure it there
instead. CLOUDFLARE_* and TUNNEL_* are both kept out of cloudflared's own environment: it is given the tunnel token and
nothing else, so a stray variable cannot redirect the tunnel and your account token is never handed to a process with no use for
it.
Your own cloudflared setup is left alone, and not used. dev runs cloudflared against a config file of its own, because an
existing ~/.cloudflared/config.yml would otherwise win: its ingress rules override the --url dev passes, and the tunnel
would answer that config's catch-all — a 404 on every webhook — instead of the site worker.
Why it takes 20-40 seconds. dev names each thing it is waiting for, and the wait is deliberate. A fresh
trycloudflare.com hostname is minted about five seconds before DNS publishes it, and trycloudflare.com tells resolvers to
remember a miss for 1800 seconds — so one lookup a moment too early makes the hostname fail for the next half hour, for you
and for the vendor you gave it to. dev therefore waits for cloudflared to register a connection, then asks the zone's own
nameservers whether the name exists yet. Those answer from zone data, so the wait itself leaves nothing behind in any cache your
browser, curl or the vendor will read. By the time the URL is printed it works on the first request. The tunnel closes when
dev does.
Only the site worker is exposed — the same worker, and the same code, that faces the internet on a deployed site. The console
is never tunneled: it installs apps and shows your variables, and it answers a loopback Host and nothing else.
Four things behave differently from a deployed site, and dev says so rather than letting you discover them:
ai,vectorize, and ahyperdrivewith no local database URL are not bound. Each needs a Cloudflare account or a database only you can name, so the app boots without those bindings and the start-up banner lists them. Give a hyperdrive its local URL in the console to bind it — the origin in your manifest is the deployed database and is never used here.- Two mounted apps cannot bind the same hyperdrive name to different databases. workerd names a hyperdrive connection after
the binding — your app's own
env.DATABASE— and keeps the first of a name, so the second app's binding is reported inert instead of silently reaching the first app's database. Rename the binding, or point both at one database. - A
pipelinewrites to a local catalog, not to Cloudflare.send()checks each record against the manifest schema, runs the sinkselecton SQLite and appends the rows tonamespace.tableunder~/.qinto/dev/<hash>/catalog/;query.catalogreads them back with SQLite plusto_timestamp_millis,date_truncand thejson_get_*functions. The banner prints the directory sosqlite3can open it, and--resetclears it. - An
analytics_enginedataset is a local SQLite file.writeDataPointstores the point with the columns Analytics Engine gives it, andquery.analyticsreads it back through the same statement gate the platform applies, plus the documented Analytics Engine functions — a name outside that list is refused locally rather than answered by SQLite, so a query that runs here runs deployed. The one thing it cannot reproduce is the throw: a point that breaks a documented limit is reported on the terminal and dropped, where a deployed hook would throw. The banner prints the file, and--resetclears it.
The UI panel. When your manifest declares a ui block, dev runs its build with the same base path a published panel is
served under — /<appId>/<version>/ — and serves the result on its own port (--ui-port, default 4302); every mounted app's
panel is served from that one port, each under its own base path, and gets its own entry in the console's Apps menu. That
port is deliberately not the console's: the panel is cross-origin, exactly as it is in production, so the postMessage origin
and event.source checks on both sides are the real ones. The console mints a real app-UI token, so the panel's calls to
${ctx.siteOrigin}/apps/<slug>/ui/* go through the site worker's own claim checks. One difference from a deployed site: there
every panel gets its own https://<slug>.appui.<zone> origin, and here they share one, so panels are not origin-isolated from
each other.
Publish — qinto app publish [dir]
publish reads qinto.app.jsonc, allocates a draft version, builds, uploads, and finalizes it. Each step is recorded in
.qinto/publish.json, so an interrupted publish resumes where it stopped when you rerun it with the same arguments; --restart
discards that state. A directory-level lock (.qinto/publish.lock) stops two publishes racing in the same checkout.
What a publish does, in order:
- Allocate a version —
--bump patch|minor|major(defaultpatch) from the highest ready version, or--version x.y.z. - Build
server.entrywith esbuild, andbrowser.entrytoo when the manifest declares one. The built server bundle is imported; its event/lifecycle hooks, backfill handlers, and workflows are compared against the manifest before anything is uploaded. - Build the UI panel, when
uiis declared, with the panel's own base path injected, then check that no asset in the built HTML points outside that prefix. - Declare and upload the artifacts. Each upload is presigned against the declared SHA-256, so what lands in storage is exactly what was built. Redeclaring replaces the previous declaration, so a failed upload is fixed by publishing again.
- Finalize the version.
artifacts_stalemeans the manifest changed after the build; the CLI rebuilds and redeclares once by itself. - Install, when
--siteis given.
| Flag | What it does |
| --- | --- |
| --bump <level> | patch (default), minor, or major, from the highest ready version. |
| --version <version> | Allocate this exact x.y.z instead of bumping. |
| --org <organization> | Owning organization id or name, when the slug exists in several. |
| --site <site> | Install the finalized version on this site (hostname or domain id). |
| --team <teamId> | Authorized team to resolve --site in. |
| --accept-capabilities | Accept the extra capabilities a newer version asks for. |
| --acknowledge-necessary | Record that this app runs without visitor consent. |
| --dry-run | Build and validate locally; allocate nothing and upload nothing. |
| --restart | Discard .qinto/publish.json and start a fresh publish. |
| --no-input | Disable all interactive prompts. |
| --json | Print the published version, and the install when --site was given, as JSON. |
Installing a version the visitor's consent or the capability set does not already cover fails until you accept it. Interactively
the CLI asks; in machine mode it exits 2 and names the flag to pass.
Inspect — status, versions, version cancel
qinto app status my-app --site example.com # desired vs deployed version, installs, endpoints, grants
qinto app versions my-app # every version, drafts included
qinto app versions my-app --limit 20 --json
qinto app version cancel my-app 1.4.0 # release a draft this checkout holdsstatus is the one command that answers "did my publish land": desired is what you published and installed, deployed is what
the site's worker is actually running, and an install is inactive until every required variable is filled in. A draft is a
version that was allocated but never finalized; cancelling one frees its version number. Only the checkout that holds the draft
can cancel it — an organization owner unwedges an abandoned draft with --force.
The manifest — qinto.app.jsonc
qinto.app.jsonc is the app: it is JSONC (comments and trailing commas allowed), it is validated before every publish, and its
digest is what the version is finalized against. Every field and option is documented in
the manifest reference; the summary:
| Field | What it declares |
| --- | --- |
| $schema | The JSON Schema your editor completes and validates the file against. The scaffold points it at ./node_modules/@blotout/qinto-sdk-app/app.schema.json, which the SDK ships, so completion works offline and matches the SDK version you installed. |
| slug, name, description | Identity. slug is immutable and must match the registered app. |
| flows | transform, destination, or both. |
| consent | The single consent category a visitor must have granted before events reach the app. |
| legal | termsUrl and privacyUrl — HTTPS, and your own documents, never a qinto.io URL. |
| server | entry plus the event and advisory lifecycle hooks the entry exports. |
| browser | entry for the bundle inside the site's q.js, optional capture names, and action types whose browser handlers return a same-event server result. |
| capabilities | The platform api calls and the event data the app is allowed to read. |
| resources | Cloudflare bindings the app worker gets: KV, D1, R2, Durable Objects, Vectorize, Hyperdrive, and more. D1 uses immutable named expand, resumable backfill, and post-convergence contract entries. |
| variables | Up to 32 per-install settings a team fills in — plain or secret, text/password/toggle. |
| appVariables | Up to 16 settings you fill in once for the whole app with qinto app var set / qinto app secret set. |
| endpoints | Up to 16 HTTP routes served at https://<site>/app/<slug><path>. |
| rpc | Functions this app exposes to other apps, and the ones it calls on them. Each exposed one is built with defineRpc, and publish uploads its argument and result schemas as the version's contract. |
| events | The push topics this app broadcasts to every app on the site. |
| ui | dir holding the built panel, and the build command that produces it. |
| install | multiple: true to let a site install the app more than once, plus a labelHint. |
Limits worth knowing before a publish: the server bundle is capped at 10 MB, the browser bundle at 2 MB (and 3 MB for all apps on one site together), the UI panel at 200 files and 25 MB, and an app at 15 installs per site. A variable value is capped at 256 serialized bytes, and one variable's values across every install of that app on a site must fit in 5 KB. Every other cap — the manifest, the publish, the install, and the runtime envelope — is listed in Qinto app limits.
Built for humans and agents alike
Every interactive path has a non-interactive equivalent. This is a standing rule for every current and future command:
| Surface | What it does |
| --- | --- |
| qinto describe [command ...] | Prints a versioned, machine-readable manifest of nested command paths, flags, environment inputs, and structured result states. |
| --json | On data and onboarding commands, prints the typed result as JSON on stdout — nothing else lands on stdout. |
| --no-browser | login prints the authorize URL instead of opening a browser; open it in a browser on the same host so its loopback callback reaches the CLI. |
| QINTO_API_TOKEN | A bearer token that takes precedence over stored logins. Works with team API tokens (qapi_, from Team → API tokens in the Qinto platform) and OAuth access tokens (qoa_). No browser needed. |
| QINTO_ENV | production or sandbox for every command in the shell — the non-interactive stand-in for qinto env, which stores the same choice. |
| QINTO_NO_UPDATE_CHECK | Any non-empty value stops the daily new-version lookup. Machine mode already suppresses it, so a script needs this only to keep the CLI off the network. |
| Exit codes | 0 success, 1 operational failure, 2 usage error, 130 aborted prompt (Ctrl-C). Errors go to stderr; with --json they are printed as {"error": "…"}. |
Environments
Qinto runs in two environments, and every command talks to exactly one of them:
| Environment | API origin | Platform |
| --- | --- | --- |
| production | https://api.qinto.io | https://portal.qinto.io |
| sandbox | https://api.qinto.tech | https://portal.qinto.tech |
qinto env picks one and remembers it, so nothing after it needs a flag:
qinto env # pick from a list; prints the current one when nothing can be prompted
qinto env sandbox # the same choice, non-interactively
qinto env --json # {"environment":"sandbox","apiOrigin":"https://api.qinto.tech"}The choice is written to ~/.qinto/settings.json and applies to every later command, login included — so the usual flow is
qinto env sandbox, then qinto login, then work as normal.
Each environment keeps its own login. Sessions are stored per API origin, so switching does not log you out of anywhere:
log in to production and to sandbox once each, and from then on qinto env moves between them with no further login. Each
environment also remembers its own active team, and renews its own access token from its own refresh token. qinto env says so
when you land on an environment you have no session on; qinto logout clears one environment — the one it is pointed at.
Four things override it, strongest first:
| Override | Scope |
| --- | --- |
| --api-url <origin> | One command, any origin — a local stack. |
| QINTO_API_URL | Every command in that shell, any origin. |
| --env <name> | One command. |
| QINTO_ENV | Every command in that shell. |
With nothing saved and nothing overriding, commands talk to production.
Credentials are stored per API origin in ~/.qinto/credentials.json (file mode 0600), so logins to different environments
coexist and each keeps its own active team. When a command finds no login for the environment it was pointed at, its error names
the login command for that same environment. Access tokens renew themselves through the refresh token; when a session can no
longer be renewed the CLI asks you to log in again. qinto logout
clears local credentials only after the server confirms the revocation — if the API is unreachable, nothing is cleared, so a
retry can still revoke the session (or revoke it under User → Authorized apps in the Qinto platform).
