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 installintellia 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 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. 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 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. 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 }, ...]
groupBytakes up to 2 keys. A key on a timestamp column may carry abucketofday,week,month,quarteroryear; the key is then the bucket’s first day asYYYY-MM-DD, and weeks start on Monday. WithoutgroupByyou get one group holding the totals of the whole filtered table.metricstakes 1 to 10 entries.countneeds no field;sumandavgneed a numeric column;minandmaxneed 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.countis a number.sum,minandmaxon numeric columns are exact decimal strings ("0.3", never0.30000000000000004).avgis a decimal string with 6 places ("1.950000").minandmaxon timestamps, and a timestamp key without abucket, are ISO strings with microseconds ("2026-03-01T09:00:00.123456"). A key with no value isnull; those rows form their own group, sorted last.orderByisname:ascorname:desc, wherenameis a group field or a metric alias. The group keys break ties.timeZoneis an IANA zone and defaults to the browser’s. It applies to the platform’screated_atandupdated_atcolumns, which hold instants and are converted to the requested zone before bucketing. Your owntimestampcolumns hold local time and are bucketed as stored, whatever the zone. The zone is checked only when a bucketedcreated_atorupdated_atkey uses it; an unknown zone there is refused withbad_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 withaggregate_timeout, and no partial figures are returned. - Validation failures reject with a
HutlyErrorwhosecodeis one ofunknown_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(anorderBythat is notname:ascorname:descover a group field or metric alias) orbad_time_zone. An unknown column infiltersis answered with the sameUnknown column "x"error asquery, notunknown_column. sumandavgare also allowed onid. 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.aggregaterequest with itstable. Answers come back in request order. - Each request is allowed, refused and capped as
tables.aggregatewould be. One that fails answers{ ok: false, error }with itsHutlyError; 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 withaggregate_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")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, 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.