@intellia/app-sdk
v0.8.0
Published
Build typed, gated embedded apps for the Intellia platform.
Readme
@intellia/app-sdk
Build typed, gated embedded apps for the Intellia platform.
A Hutly app is a single-file HTML bundle that runs inside a Hutly canvas. It
never talks to the backend directly — it calls workflows and reads/writes
tables through the window.hutly client that this package installs, and the
build toolchain gates it: a broken or undeclared reference fails at build
time, so a broken app can never publish.
Install
yarn add @intellia/app-sdkThe CLI commands below (intellia app ...) come from @intellia/cli.
Quickstart
intellia app init my-app
cd my-app && yarn installThis scaffolds a project with a hutly.manifest.ts, an index.html entry,
and a Vite build already wired for a single-file bundle.
1. Declare what your app needs
Every workflow slug and table your app touches must be declared in
hutly.manifest.ts:
import { defineManifest } from "@intellia/app-sdk";
export default defineManifest({
workflows: ["create-lead"],
tables: [{ table: "leads", access: "write" }],
});defineManifest is an identity function that gives you type checking against
the AppManifest shape — nothing more.
An app that needs to send a user to a URL outside the platform lists it under
externalUrls, an optional array of https://-only patterns:
export default defineManifest({
workflows: ["create-lead"],
tables: [{ table: "leads", access: "write" }],
externalUrls: ["https://*.hutly.com/*"],
});See "Route and theme with the host" below for what navigate does with a URL
that matches.
2. Call the backend through window.hutly
import "@intellia/app-sdk"; // installs window.hutly
if (!window.hutly) throw new Error("not running inside a Hutly host");
const ctx = await window.hutly.ready;
if (ctx.signedIn) {
const result = await window.hutly.call("create-lead", { name: "Ada" });
// orderBy is comma-joined "field:asc|desc" segments — never "-field".
const leads = await window.hutly.tables.query("leads", {
limit: 20,
orderBy: "id:desc",
});
}window.hutly.call(workflowSlug, input) and window.hutly.tables.* (query,
insert, update, delete) are the only way an app reaches the backend.
There is no raw fetch, no API key, and no direct database access available
to app code — the host validates every call against the app's approved
manifest before it runs.
3. Route and theme with the host
An app can see the host's real sub-path/query and live theme, and navigate without a full reload:
import "@intellia/app-sdk";
if (!window.hutly) throw new Error("not running inside a Hutly host");
window.hutly.route.get(); // { path: "/inbox/42", search: "?foo=bar" }
window.hutly.route.subscribe(() => console.log(window.hutly?.route.get()));
window.hutly.theme.get(); // "light" | "dark", follows the host's live theme
window.hutly.theme.subscribe(() => console.log(window.hutly?.theme.get()));
window.hutly.theme.set("dark"); // pin a fixed theme; set("auto") releases it
await window.hutly.navigate("/inbox/43", { replace: true }); // relative to
// this app's own mounted sub-path, not the whole site
// On an app site, an absolute URL on the site's own origin routes the site to
// another app mounted there — see "Linking to another app on the same site":
await window.hutly.navigate("https://acme.apps.hutly.com/industry-awards");
// Any other absolute https:// URL is accepted if it matches a pattern in
// externalUrls. It opens in a new tab unless you say otherwise:
await window.hutly.navigate("https://docs.hutly.com/apps");
await window.hutly.navigate("https://docs.hutly.com/apps", { target: "_self" }); // same tab
await window.hutly.navigate("https://docs.hutly.com/apps", { replace: true }); // same tab, no history entry| opts | result |
|---|---|
| (none) or { target: "_blank" } | new tab |
| { target: "_self" } | same tab (location.assign) |
| { replace: true } | same tab (location.replace), no history entry |
| { target: "_blank", replace: true } | rejected — a new tab that replaces itself is a contradiction |
A pattern's host may lead with *. to match one or more subdomains — it never
matches the bare apex, so list the apex as its own entry if you need it — over
a registrable domain; a public suffix like co.uk or github.io is refused,
since it would grant every site hosted under it. Path segments are literal or
a bare *: a trailing /* grants everything under that path at any depth,
including none (/docs/* matches /docs); a * earlier than the last
segment matches exactly one non-empty segment (/customer/*/cart, never
/customer/8/1/cart). A trailing slash is insignificant, so /customer/*/
grants the same depth as /customer/* — not "one segment under /customer".
A URL the manifest doesn't allow rejects with navigate_denied. One the
browser's popup blocker refused rejects with navigate_blocked — the host
shows the visitor a recovery dialog rather than failing silently.
Linking to another app on the same site
An app site mounts several published apps on one host, each at its own path. From one of them, a plain anchor to another is all you need — the SDK claims the click and asks the site to route it, so the visitor lands on the other app with no full page load and no SDK call in your code:
<a href="/industry-awards">Industry awards</a>Two rules decide where such an href points:
- Root-relative (
/industry-awards) resolves against the site root. - Relative (
awards,./awards) resolves against your app's mount —/events/awardsfor an app mounted at/events. That is the mount, not your app's current in-app route:<a href="detail">from/inbox/42goes to<mount>/detail, never<mount>/inbox/detail. Your document is served from ablob:URL whose base has no path to resolve against, so the mount is what the SDK substitutes.
The SDK claims a click only when it is a plain left click with no modifier key,
on an anchor whose target is absent, empty, _self, _top or _parent
(matched case-insensitively, so _SELF counts), and whose href lands on the
site's own origin. Everything else stays the browser's:
target="_blank" and named targets, in-page #hash links, mailto:, another
origin. A handler that already called preventDefault() wins, so a router's
<Link> keeps its own behaviour.
window.hutly.navigate reaches the same destination from code, as an absolute
URL on the site's origin:
await window.hutly.navigate("https://acme.apps.hutly.com/industry-awards");A root-relative <a href> is the portable form. A hard-coded
https://<slug>.apps.hutly.com/… is a different origin to a visitor who reached
the site on its custom domain, so it takes the external path: denied unless
externalUrls allowlists it, and navigate_unsupported on a public page, which
opens no external URLs. Use the root-relative form.
An app can only reach another app on the same site. The site resolves the
target path against its registry of mounted apps and refuses anything else, so
/sso and every other route the shell serves itself is unreachable from app
code. A refused destination rejects with host_error and the message
navigate_failed; an intercepted anchor click has no promise to reject, so the
SDK logs [hutly-sdk] link navigation failed and the click does nothing.
All of this exists only on an app site. The in-workspace App viewer has no
sibling apps to reach: it leaves anchors to the browser, and an absolute URL on
the workspace's own origin is treated like any other external destination —
denied, and logged, unless externalUrls grants it.
You cannot exercise a sibling link in intellia app dev. The local harness is
not a site either, so the SDK leaves the anchor to the browser and the harness's
static server has no such file — it answers 404, and the click replaces your app
in the frame with a plain "not found". In the workspace App viewer the same link
does nothing at all, because the app document there has no base path for the
browser to resolve it against. Neither outcome is a fault in your markup, and
neither is worth debugging: a sibling link only resolves on a real app site, so
that is where to check it.
An app mounted at / is matched on the exact path / only. Any deeper path
resolves to whichever other app is mounted at that prefix, or to nothing at all,
so a root-mounted app cannot route the host to its own sub-paths. Mount an app
that needs its own routes at a real path (/events) rather than at the site
root.
Building with React, @intellia/app-sdk/react wraps this in the usual hooks
and a react-router-dom integration:
import { useHutlyRoute, useHutlyTheme, HutlyRouter } from "@intellia/app-sdk/react";
import { Routes, Route } from "react-router-dom";
// useHutlyRoute() / useHutlyTheme() are useSyncExternalStore wrappers around
// window.hutly.route / .theme — re-render on every live change.
createRoot(document.getElementById("app")!).render(
<HutlyRouter>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</HutlyRouter>,
);HutlyRouter bridges window.hutly.route/.navigate() into an ordinary
react-router-dom <Router>, so useNavigate(), <Link>, and nested
<Routes> all work as normal — a click on a <Link to="/inbox/43"> calls
window.hutly.navigate under the hood, so it always stays inside your own app.
Use a plain <a href> to reach another app on the site. intellia app init
scaffolds an app this way by default; pair it with @intellia/app-ui for
on-brand primitives.
Save a file or print a document
An app runs in a sandboxed frame that cannot download or open the print dialog itself, so the host does both for it. Declare what the app needs:
export default defineManifest({
workflows: [],
tables: [{ table: "orders", access: "read" }],
downloads: true, // hutly.files.download()
print: true, // hutly.print()
});Both show in the approval dialog, so a manifest that adds one needs
re-approving. ctx.downloads / ctx.print on await window.hutly.ready say
whether the call will work here; they are false without the grant and in the
mobile app, which can do neither (calls there reject unsupported_platform).
const ctx = await window.hutly.ready;
// The file is saved on the user's device; nothing is uploaded to the workspace.
const { filename } = await window.hutly.files.download({
filename: "orders-2026-10.csv",
mimeType: "text/csv",
data: "\uFEFFid,total\n1,42.00\n", // string, Blob, ArrayBuffer or Uint8Array
});
// Opens the print dialog, where the user can also choose Save as PDF.
await window.hutly.print({
title: "Invoice INV-0042", // the suggested PDF file name
html: `<style>body { font-family: sans-serif }</style><h1>Invoice INV-0042</h1>`,
});files.download saves text/csv, text/plain, application/json,
application/pdf and .xlsx, up to 20 MB; any other type rejects with
disallowed_file_type (HTML and SVG are refused because they run when opened
from disk) and a larger file with file_too_large. The filename loses path
separators and characters operating systems refuse, and gains the type's
extension if it lacks it (report.exe as CSV saves as report.exe.csv); the
result is the name it was saved under. Excel reads a CSV as UTF-8 only with a
leading BOM (\uFEFF). A user-input cell that starts with =, +, -
or @ runs as a formula even when quoted, so prefix it with an apostrophe
before the usual CSV quoting: =1+1 becomes '=1+1, not "=1+1".
Both need the user's gesture, so call them from a click handler. Within about
5 seconds of the click the file saves (or the dialog opens) at once; a request
later than that, such as an export that spent longer fetching rows, or a second
one on the same click, makes the host show " wants to save orders.csv" with
Save and Cancel, and the action runs only when the user presses it. Cancel, or
two minutes without an answer, rejects output_cancelled. One request waits at
a time (output_busy) and at most 5 run a minute (output_rate_limited), so
an app cannot loop dialogs or spray files. Neither call has a deadline while the
host waits.
print takes up to 5 MB of HTML and prints a copy with scripts, event
handlers, forms, frames, <link> and <meta> tags, and every remote URL
removed, under a policy that blocks network loads. Put CSS in a <style>
element (use @media print and @page as normal) and embed images as
data:image/... URLs. The promise resolves once the dialog has opened. Without the grant the calls reject downloads_not_in_manifest /
print_not_in_manifest.
4. Build
intellia app buildbuild runs, in order: manifest-aware codegen, tsc --noEmit, eslint .,
a manifest-consistency check, then the Vite single-file bundle, then
publishes the resulting dist/index.html as an artifact. Any failure aborts
the whole command — nothing partial gets published.
Use intellia app validate to run everything except the bundle/publish step
(useful in CI or as a pre-commit check).
The build gate
The toolchain enforces two things no amount of hand-authored HTML can bypass
in intellia app build/validate:
- Undeclared references fail. If your source calls
hutly.call("some-slug")orhutly.tables.query("some-table")for a slug or table not listed inhutly.manifest.ts,validate/buildfails with a manifest-consistency error. - Shape mismatches fail.
intellia app codegengenerateshutly.gen.tstypings from your manifest's real workflow/table schemas; a wrong input shape is atsccompile error, not a runtime surprise.
The generated ESLint ruleset (@intellia/app-sdk/eslint, already wired into
the scaffolded eslint.config.js) additionally bans raw fetch,
XMLHttpRequest, WebSocket, dynamic import(), and document.write in app
source — the only sanctioned backend access is window.hutly.
This is a correctness/DX gate, not the security boundary: the runtime enforcement that actually contains an app (approved manifest ∩ viewer capability) lives in the Hutly host, not in this toolchain.
What's in this package
- Runtime client (
window.hutly) — bundled byintellia app buildinto your app; also available as@intellia/app-sdkfortsc/type checking. defineManifest,HutlyError,PROTOCOL_VERSION— exported from the package root.@intellia/app-sdk/react—useHutlyRoute,useHutlyTheme,HutlyRouter.@intellia/app-sdk/eslint— the shared ESLint ruleset described above.@intellia/app-sdk/template— the project scaffold used byintellia app init, built on@intellia/app-sdk/reactand@intellia/app-ui.
