Apps
A Hutly app is a small, typed, single-file HTML app that a user pins as
an App inside their Hutly workspace. It runs sandboxed inside the
platform and only ever reaches the backend through a window.hutly client
that the host injects at runtime — there is no raw fetch, no API key, and
no direct database access available to app code.
Apps are built and published with the Hutly CLI (hutly) via
@intellia/app-sdk. See the CLI Reference for how the CLI
itself is installed and authenticated.
The loop
hutly app init my-app
cd my-app && yarn installhutly app init <dir> scaffolds a React project: hutly.manifest.ts, an
index.html entry, src/main.tsx that installs window.hutly and renders
the app through HutlyRouter, and a Vite config already wired to produce a
single-file bundle. The starter UI is built from @intellia/app-ui’s
on-brand primitives (Button, Input, Textarea, Select, Card, Text),
so a new app looks like a Hutly surface from the first commit.
1. Declare scope in hutly.manifest.ts
Every workflow slug and table an app touches must be declared up front:
import { defineManifest } from "@intellia/app-sdk";
export default defineManifest({
workflows: ["create-lead"],
tables: [{ table: "leads", access: "write" }],
files: true, // only if the app uploads documents
externalUrls: ["https://*.hutly.com/*"], // only if the app links offsite
});defineManifest is an identity function — it only exists to type-check the
object against the AppManifest shape. This manifest is the app’s entire
declared scope: nothing outside it will ever be callable.
2. Write the app against window.hutly
import "@intellia/app-sdk"; // installs window.hutly
const ctx = await window.hutly.ready;
if (ctx.signedIn) {
const lead = await window.hutly.call("create-lead", { name: "Ada" });
const leads = await window.hutly.tables.query("leads", { limit: 20 });
}window.hutly.call(workflowSlug, input) and window.hutly.tables.query /
insert / update / delete are the only way an app reaches the
backend. Run hutly app codegen to generate hutly.gen.ts typings from the
manifest’s real workflow input schemas and table columns, so a wrong input
shape becomes a tsc error rather than a runtime surprise.
3. Run it locally
An app has no window.hutly outside a host, so opening dist/index.html
directly shows a blank page — the app waits on a host that never answers.
Two commands put a host in front of it:
hutly app dev
hutly app servedev answers the app’s SDK calls in memory, with no platform round-trip;
serve answers them from the platform, using your CLI credentials.
Both serve the app you last built at http://localhost:5180 with the real app
SDK injected, exactly as the platform injects it. dev answers table and
workflow calls from memory: declared tables start empty and accept inserts, and
you can seed them (and canned workflow outputs) from an optional
hutly.fixtures.json:
{
"tables": { "deals": [{ "id": 1, "name": "Beacon", "stage": "New" }] },
"workflows": { "summarise-deal": { "summary": "…" } }
}Both enforce the manifest the way the gateway does — a table you did not
declare fails with table_not_in_manifest on your machine rather than in
production. Neither relays agents, file uploads or the live event tunnel; those
need the real host.
4. Build
hutly app buildbuild runs, in order: manifest-aware codegen → tsc --noEmit → eslint .
→ a manifest-consistency check → the Vite single-file bundle → publish the
resulting dist/index.html as an artifact. Any step failing aborts the whole
command, so a broken app can never publish. hutly app validate runs the
same checks without the bundle/publish step, useful in CI or as a pre-commit
check.
build also captures the project’s source tree after publishing, registered
against the published artifact version, so hutly source pull artifact_version <versionId> -o <dir> can recover the exact authored project later — this
writes an artifact_version ref only; there is no app_site ref from a plain
app build, even when the artifact is pinned as an App. (Recovery keyed on an
app site applies to a module’s app_site, deployed via hutly modules deploy,
not to a standalone app build.) Capture defaults to the nearest ancestor
directory holding module.yaml or .hutlyrc.yaml; pass --source-root <path>
to capture a different directory instead.
Updating an app that is already live
A plain hutly app build creates a new artifact each time. An app site pins
its app to an artifact id, and the app-site update endpoint only accepts a
versionId resolved against that same artifact — so pointing a live site at a
freshly built artifact fails with artifact_version_not_found.
Ship changes to a live app as the next version of the artifact the site already serves:
hutly app build --append-to <artifactId>The command prints both the artifact id and the new version id; approve the
manifest for that version, then point the site’s app at it. Use plain app build only for an app no site serves yet.
The window.hutly surface
| Member | Shape | Notes |
|---|---|---|
version |
number |
Protocol version the running host speaks. |
ready |
Promise<HutlyContext> |
Resolves once the host has handshaked; HutlyContext is { signedIn, organizationId?, user?, workflows, tables, agents, files }. files is true only when the manifest granted uploads. |
isSignedIn() |
() => boolean |
Synchronous read of the current context. |
getContext() |
() => HutlyContext |
Synchronous read of the full context. |
call(slug, input?) |
Promise<T> |
Invokes a workflow by slug. |
tables.query/insert/update/delete |
Promise<T> |
CRUD against a declared table. query takes orderBy as comma-joined field:asc|desc segments ("id:desc"); the JSON:API "-field" form is rejected with a 400. |
files.upload(file, opts?) |
Promise<AppUploadedFile> |
Uploads a File and resolves { artifactId, versionId, filename, mimeType, sizeBytes }. Requires files: true in the manifest. opts.onProgress(loaded, total) and opts.signal (an AbortSignal) are supported. |
navigate(to, opts?) |
Promise<void> |
Moves the app-relative path. On an app site, an absolute URL on the site’s own origin routes the site to another app mounted there — see app sites. Any other absolute https:// URL opens only if externalUrls allowlists it, and only in a viewer that opens external URLs at all. See “Navigating to external URLs” below. |
call and every tables.* method reject with a typed HutlyError rather
than silently doing nothing. The error codes an app should be prepared to
handle: not_signed_in, app_not_approved, workflow_not_in_manifest,
table_not_in_manifest, protocol_mismatch, host_error, timeout,
navigate_denied, navigate_blocked, navigate_unsupported.
Uploading files
An app that declares files: true can upload documents:
const { artifactId } = await window.hutly.files.upload(file, {
onProgress: (loaded, total) => showPercent(loaded / total),
});
await window.hutly.call("agreement-extract", { artifactId });The bytes go straight from the app’s iframe to storage over a short-lived
presigned URL — they never pass through the API, so uploading a large document
costs one request rather than streaming through a Lambda. The upload becomes a
library artifact in the calling user’s organisation, so it appears under Files
and can be sent to a knowledgebase like any other artifact. Pass the returned
artifactId into a workflow input to let the backend read the contents.
Uploads are refused when the approved manifest doesn’t grant files, when the
file type is an executable, or when it exceeds the artifact size cap. A public
app cannot declare files — uploading requires a signed-in organisation user.
Navigating to external URLs
window.hutly.navigate accepts an app-relative path, as before, or an
absolute https:// URL — if the URL matches a pattern in the manifest’s
externalUrls. An app has no other way to send a user offsite: it runs in a
sandboxed frame with no popups and no top-level navigation of its own, so only
the host can open the destination, and only once it has checked the approved
manifest.
export default defineManifest({
externalUrls: ["https://*.hutly.com/*"],
});await window.hutly.navigate("https://docs.hutly.com/apps");A leading *. in a pattern’s host matches one or more subdomain labels and
never the bare apex — an author who also needs the apex lists it as its own
entry. The host below the wildcard must be a registrable domain: a public
suffix such as co.uk, com.au, github.io or vercel.app is refused,
since granting it would open every site hosted underneath. In the path, a *
in the last segment matches any remaining path at any depth, including none —
https://*.hutly.com/docs/* matches .../docs, .../docs/a and
.../docs/a/b/c, and https://*.hutly.com/* matches the bare origin and
everything under it. A * earlier than the last segment matches exactly one
non-empty segment and never crosses a / — /customer/*/cart matches
/customer/812345/cart but not /customer/8/1/cart. A trailing slash makes
no difference to matching, so /customer/*/ grants the same thing as
/customer/* — any depth under /customer, not one segment beneath it.
Only the host and path are checked — the query string and fragment are
not. A granted site that forwards visitors on (an open redirect, a
?next= parameter, a login bounce) can still take someone somewhere the
pattern never named. Grant the narrowest path you actually need, and treat
a site you don’t control as a site that can hand your users onward.
An absolute navigate opens a new tab by default. target/replace
change that:
opts |
result |
|---|---|
(none) or { target: "_blank" } |
new tab |
{ target: "_self" } |
same tab, forward history preserved |
{ replace: true } |
same tab, no history entry added |
{ target: "_self", replace: true } |
same tab, no history entry added |
{ target: "_blank", replace: true } |
rejected — a new tab that replaces itself is a contradiction |
External navigation is wired in the in-workspace App viewer (ui/) only.
The standalone app-sites host routes paths on the site itself, including an
absolute URL on the site’s own origin — that is how one app links to another
(see app sites). It opens no external URLs, so an
absolute navigate to any other origin rejects navigate_unsupported there:
the viewer’s answer, not the manifest’s.
Where it is wired, a URL that doesn’t match any pattern in externalUrls
rejects with navigate_denied — including when the manifest declares no
externalUrls at all. A new tab the browser’s popup blocker refused rejects
with navigate_blocked; the host shows the visitor a dialog offering to
open the destination directly, rather than failing with nothing to show for
it.
The build gate
The toolchain enforces two things no amount of hand-authored HTML can bypass:
- Undeclared references fail. Calling
hutly.call("some-slug")orhutly.tables.query("some-table")for a slug or table not listed inhutly.manifest.tsfailsvalidate/buildwith a manifest-consistency error. - Shape mismatches fail. The generated
hutly.gen.tstypings come from the manifest’s real workflow/table schemas, so a wrong input shape is atsccompile error, not a runtime surprise.
The scaffolded ESLint config 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 (an approved manifest intersected with the viewer’s capability) lives in the Hutly host, not in the build toolchain.
Approval
Building and publishing an app is not enough to make it live. An app only
runs once its artifact is pinned as an App, and pinning an artifact whose
declared manifest isn’t approved yet stops to show an admin a plain-language
scope dialog listing the workflow slugs it can run, the tables it can read or
write, and — under “Sites it can open” — any externalUrls patterns it
declares. An admin has to explicitly approve that scope and pin — until they
do, every window.hutly.call/tables.* invocation from that artifact fails
with app_not_approved, and the app isn’t wired to reach the host’s
navigation bridge at all. This is enforced on every request by the host, not
just in the pin-time dialog.
Approval is scoped to a specific artifact version. Publishing a new version
of an app is a new version with its own approval state, so expect to
re-approve after a rebuild/republish if app_not_approved shows up again.