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 Intellia CLI (intellia) via @intellia/app-sdk. See the CLI Reference for how the CLI itself is installed and authenticated.

The loop

intellia app init my-app
cd my-app && yarn install

intellia 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. The same package has the pieces data-heavy screens need: DataTable, Dialog, Tabs, DatePicker, Combobox, NumberInput, StatusChip, Keypad, and ToastProvider / useToast. The useToast() methods success, error and info return the toast’s id; dismiss(id) removes it, and passing the same { id } option to a later call replaces that toast instead of stacking a new one. Error toasts stay until dismissed, so clear or replace them once the next attempt succeeds.

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
  addressLookup: true, // only if the app offers an address typeahead
  members: true, // only if the app lists the workspace's members
  downloads: true, // only if the app saves files it builds (CSV exports)
  print: true, // only if the app prints documents (print dialog, Save as PDF)
  permissions: ["microphone"], // only the device access the app uses
  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.

A read grant can be limited to the signed-in user’s own rows by naming the column that holds their user id:

tables: [{ table: "intellia_erp_user_role", access: "read", ownRows: "user_id" }],

The server then adds user_id = <signed-in user> to every query, count and aggregate on that table, around whatever filters the app sends, so the app never reads another user’s rows from it. ownRows is read-only (a write grant cannot carry it), the approval screen shows the narrower grant, and another app on the same site cannot count or total the table through it. It limits reads of that table only: another table the app reads that links to it still shows each linked row’s display column, as it does for a table the app has no grant on.

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 intellia 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.

hutly.call waits for the tool workflow to finish, for up to five minutes. A workflow that needs longer belongs in a standard workflow started with hutly.workflows.runAsync. An app that bundles @intellia/app-sdk from npm needs 0.6.2 or newer; older versions give up on the call after 30 seconds.

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:

intellia app dev
intellia 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. With members: true granted, members.list() answers dev from a "members" array of { userId, name, email, orgRole } in the same file, or one stub member when there is none; serve lists your organisation’s members. Neither relays agents, file uploads or the live event tunnel; those need the real host. serve starts each workflow call as a Run, so the workflow sees triggerId manual there, where a deployed app’s call carries app; check a branch on app in a deployed app.

4. Build

intellia app build

build 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. intellia 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 intellia 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 intellia modules deploy, not to a standalone app build.) Capture defaults to the nearest ancestor directory holding module.yaml or .intelliarc.yaml; pass --source-root <path> to capture a different directory instead.

Updating an app that is already live

A plain intellia 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:

intellia 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, addressLookup, downloads, print, permissions, site? }. files and addressLookup are true only when the manifest granted them; downloads and print also need a host that can save and print, which the mobile app is not, and permissions lists the approved device permissions. site is set only on an app site: { apps: [{ path, title, showInNav }] }. user is { id, name?, email?, role? }; role is the user’s workspace role (owner, admin, chat or app_user), the same value a workflow they start sees as triggeredByRole, so an app can shape its screens without a workflow round trip. The server still checks every call.
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 returns up to limit rows from offset, with totalRows. limit is an integer from 1 to 500 (default 20) and offset an integer of 0 or more; anything else fails with invalid_field. A filters, orderBy, limit or offset of the wrong type (filters and orderBy are strings) is refused before the request is sent (invalid_filters, invalid_order_by, invalid_limit, invalid_offset), never dropped. orderBy is comma-joined field:asc|desc segments ("id:desc"); the JSON:API "-field" form is rejected with a 400. filters is field:op:value:join segments joined by |||; op is eq, ne, gt, gte, lt, lte, like, ilike, in / nin (comma-separated values; nin also excludes NULL rows), is_null or not_null (no value: "parent_id:is_null::and"). A single segment with no ||| is read as one in / nin list only when it has exactly four : fields ("id:in:2,4:and"); any other filter string without ||| is split at every ,, so a value that contains , (or a list value that contains :) needs |||. like and ilike work on text columns only and are refused with a 400 on any other column. A value the column cannot hold, such as a malformed date, is refused with a 400 Invalid filter value: ….
tables.transaction(writes) Promise<TransactionWriteResult[]> Several writes across your writable tables in one transaction; every table in the request needs write in the manifest. From an app each update and delete addresses one row by id, the same as tables.update and tables.delete do; a transaction lets several of those commit together, and a where without an id is refused with single_row_write_required. The generated hutly.gen.d.ts narrows table to your writable tables for autocomplete, but TypeScript cannot reject a table outside the manifest because the SDK’s base signature accepts any table — the gateway refuses it at runtime with table_not_in_manifest.
tables.count(table, opts?) Promise<{ count, capped? }> How many rows match opts.filters (same syntax as query), counted by the platform; no row reaches the app. Allowed on a table the manifest declares, or one another app on the same app site declares. Counting stops at 10,000 and sets capped: true.
tables.aggregate(table, request) Promise<{ groups, capped }> Grouped totals computed by the platform; no row reaches the app. request is { filters?, groupBy?, metrics, orderBy?, timeZone? }. Allowed on a table the manifest declares (read or write), or, as for count, one another app on the same app site declares. See “Aggregating rows” below.
members.list() Promise<{ userId, name, email, orgRole }[]> The workspace’s members, orgRole being owner, admin or chat; app users (site sign-ups) aren’t listed, and email is "" when a member has none. Requires members: true in the manifest, refused with members_not_in_manifest without it. Refused with members_not_for_app_users when the person using the app is an app user.
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.
files.download({ filename, mimeType, data }) Promise<{ filename }> Saves a file the app built to the user’s device; nothing is uploaded. mimeType is text/csv, text/plain, application/json, application/pdf or the .xlsx type, else disallowed_file_type; data is a string, Blob, ArrayBuffer or Uint8Array of at most 20 MB, else file_too_large. The filename is cleaned and given the type’s extension; the result is the name saved. Call it from a click handler: within about 5 seconds of the user’s click it saves at once, otherwise the host asks the user to press Save first (output_cancelled if they don’t). One request at a time (output_busy), at most 5 a minute (output_rate_limited). Requires downloads: true, refused with downloads_not_in_manifest without it, and with unsupported_platform in the mobile app.
print({ html, title? }) Promise<void> Opens the print dialog (and Save as PDF) for up to 5 MB of HTML. The host prints a copy with scripts, event handlers, forms, frames, <link>/<meta> tags and remote URLs removed, and blocks network loads, so inline CSS in <style> and images as data: URLs. title is the suggested PDF file name. Same click rule, Print prompt and limits as files.download. Resolves once the dialog opens. Requires print: true, refused with print_not_in_manifest without it, and with unsupported_platform in the mobile app.
address.search(query, opts?) Promise<AppAddressSuggestion[]> Google Places suggestions for a partial address, proxied by the host. Returns [] under 3 characters. opts.jurisdiction restricts to one AU state. Requires addressLookup: true in the manifest.
address.resolve(placeId) Promise<AppResolvedAddress> The structured components behind a suggestion: { unitNumber, streetNumber, streetName, suburb, state, postcode }.
speech.dictate(opts) Promise<{ stop() }> Live speech-to-text. opts takes language, onTranscript({ text, isFinal }), onEnd and onError. Requires microphone.
media.recordAudio(opts?) { result, stop() } Records audio; result resolves { audio, mimeType, durationMs, transcript?, transcriptError? }. opts.transcribe adds a transcript. Requires microphone.
media.capturePhoto() Promise<{ image, mimeType }> Asks the user to take a photo. Requires camera.
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.

Every row also carries four platform-written columns — created_at, created_by, updated_at, updated_by — recording who wrote it and when. tables.query returns them like any other column, but they are read-only from an app: the platform writes its own values. An update may send a row back as it was read: created_at and created_by are accepted when they match the row, and updated_at and updated_by are ignored because the update stamps them, so saving twice from the same copy works. An update that changes created_at or created_by, an insert that sends any of the four, and a tables.transaction write that sets one are refused. The generated hutly.gen.d.ts types all four as string | null: the timestamps are null on rows written before the columns existed, and the user ids are null when no signed-in person made the write. Its insert and update data types leave out id and all four.

Aggregating rows

const { groups, capped } = await window.hutly.tables.aggregate("intellia_erp_invoice", {
  filters: "status:eq:issued:and",
  groupBy: [{ field: "party_id" }, { field: "invoice_date", bucket: "month" }],
  metrics: [{ op: "sum", field: "total_incl_tax", as: "total" }, { op: "count", as: "n" }],
  orderBy: "total:desc",
});
// groups: [{ party_id: 7, invoice_date: "2026-10-01", total: "1200.50", n: 3 }, ...]
  • groupBy takes up to 2 keys. A key on a timestamp column may carry a bucket of day, week, month, quarter or year; the key is then the bucket’s first day as YYYY-MM-DD, and weeks start on Monday. Without groupBy you get one group holding the totals of the whole filtered table.
  • metrics takes 1 to 10 entries. count needs no field; sum and avg need a numeric column; min and max need a numeric or timestamp column. Each alias (as) must match /^[a-z_][a-z0-9_]*$/, be at most 63 characters, be unique, and not repeat a group field.
  • count is a number. sum, min and max on numeric columns are exact decimal strings ("0.3", never 0.30000000000000004). avg is a decimal string with 6 places ("1.950000"). min and max on timestamps, and a timestamp key without a bucket, are ISO strings with microseconds ("2026-03-01T09:00:00.123456"). A key with no value is null; those rows form their own group, sorted last.
  • orderBy is name:asc or name:desc, where name is a group field or a metric alias. The group keys break ties.
  • timeZone is an IANA zone and defaults to the browser’s. It applies to the platform’s created_at and updated_at columns, which hold instants and are converted to the requested zone before bucketing. Your own timestamp columns hold local time and are bucketed as stored, whatever the zone. The zone is checked only when a bucketed created_at or updated_at key uses it; an unknown zone there is refused with bad_time_zone.
  • Answers stop at 1,000 groups (capped: true); add a filter or a coarser bucket. An aggregate that runs longer than 10 seconds is refused with aggregate_timeout, and no partial figures are returned.
  • Validation failures reject with a HutlyError whose code is one of unknown_column (a group or metric field the table lacks), metric_type, bucket_type, too_many_group_keys, too_many_metrics, duplicate_alias, bad_alias, bad_order_by (an orderBy that is not name:asc or name:desc over a group field or metric alias) or bad_time_zone. An unknown column in filters is answered with the same Unknown column "x" error as query, not unknown_column.
  • sum and avg are also allowed on id. Aggregates consume no credits.

A dashboard that shows many totals at once can ask for them in one round trip:

const [open, overdue] = await window.hutly.tables.aggregateMany([
  { table: "intellia_erp_sales_order", filters: "status:eq:draft:and", metrics: [{ op: "count", as: "n" }] },
  { table: "intellia_erp_customer_invoice", filters: "status:eq:issued:and", metrics: [{ op: "sum", field: "outstanding_amount", as: "owed" }] },
]);
if (open.ok) console.log(open.result.groups[0].n);
  • Up to 40 requests, each a tables.aggregate request with its table. Answers come back in request order.
  • Each request is allowed, refused and capped as tables.aggregate would be. One that fails answers { ok: false, error } with its HutlyError; the others still answer.
  • The batch shares one 25-second budget and one budget of 10,000 groups. A request the time budget no longer covers is refused with aggregate_timeout; one past the group budget with aggregate_batch_too_large.

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, files_not_in_manifest, address_not_in_manifest, microphone_not_in_manifest, camera_not_in_manifest, capture_busy, capture_cancelled, unsupported_platform, permission_denied, recognizer_unavailable, start_failed, recognition_failed, protocol_mismatch, host_error, timeout, navigate_denied, navigate_blocked, navigate_unsupported, plus the tables.aggregate codes listed under “Aggregating rows”.

Who can call a workflow, and what it knows about them

Any signed-in member of the organisation can call the workflows an approved manifest declares; approval is the permission. The workflow learns who called it from intelliaContext, which the platform writes and the app cannot: triggeredByUserId is the caller and triggeredByRole is their role in the organisation (owner, admin, chat or app_user), with triggerType json and triggerId app. A workflow that enforces its own permissions reads these, not anything in its input. When triggeredByRole is null or absent, the caller holds no role in the organisation: grant them nothing. Check for the role you allow, such as triggeredByRole === "admin", rather than ruling one out.

Device access

An app can use the microphone, camera, location, clipboard and share sheet once its manifest declares them:

export default defineManifest({
  files: true, // needed to upload a recording
  permissions: ["microphone", "geolocation"],
});

You can declare microphone, camera, geolocation, clipboard-read, web-share, picture-in-picture and autoplay. clipboard-write and fullscreen are always on. The build fails on a use without its declaration, and the admin approves the permissions when they change.

Microphone and camera never work through navigator.mediaDevices inside an app, and a build that calls getUserMedia fails. Use the host calls instead. Before it listens or records, the host asks “{your app} wants to use your microphone” and waits for the user to tap Start, the same as the photo prompt. While it listens or records, the bar names your app and offers Stop.

// Live transcription
const session = await window.hutly.speech.dictate({
  language: "en-AU",
  onTranscript: ({ text, isFinal }) => (isFinal ? commit(text) : preview(text)),
  onEnd: () => setListening(false),
  onError: (err) => showError(err.code),
});
await session.stop();

// Voice note
const recording = window.hutly.media.recordAudio({ transcribe: true });
const { audio, transcript } = await recording.result; // call recording.stop() to finish
await window.hutly.files.upload(new File([audio], `note.${audio.type.includes("mp4") ? "m4a" : "webm"}`, { type: audio.type }));

// Photo
const { image } = await window.hutly.media.capturePhoto();

capturePhoto always asks the user to tap Take photo. dictate and recordAudio ask the user to tap Start. Each rejects capture_cancelled if the user taps Cancel, which is a normal outcome, as is the prompt closing by itself after 2 minutes untapped. These calls have no SDK timeout: the host always settles them, however long the user spends in a permission dialog or the photo picker. Call them from the user’s own tap on your control, so the prompt answers a request they just made. Recordings stop after 10 minutes of recording. Chrome ends dictation after a long silence; onEnd fires and you can call dictate again. There is no live audio or video stream.

Location, clipboard and share use the normal browser APIs once declared. To hide a control the app cannot use, check (await window.hutly.ready).permissions.

intellia app dev refuses microphone and camera with unsupported_platform. Deploy the app and open it from Apps to test them.

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 and in the sign-in apps on an app site. An app site also 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). A site’s public pages open no external URLs, so an absolute navigate to another origin from one rejects navigate_unsupported: 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. In the workspace the host then shows the visitor a dialog offering to open the destination directly; an app site shows none, so catch the rejection and offer the link yourself.

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, whether it can see the workspace’s members, 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.