Skip to content

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 install

hutly 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 serve

dev 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 build

build runs, in order: manifest-aware codegen → tsc --noEmiteslint . → 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") or hutly.tables.query("some-table") for a slug or table not listed in hutly.manifest.ts fails validate/build with a manifest-consistency error.
  • Shape mismatches fail. The generated hutly.gen.ts typings come from the manifest’s real workflow/table schemas, so a wrong input shape is a tsc compile 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.