Interfaces & apps
An interface is a web page (HTML, CSS, and JavaScript) rendered inside your workflow. The workflow is the backend and the interface is the frontend: you feed data into the page with variable mapping, the page sends user input back with action mapping, and by chaining interfaces you turn a workflow into an app your team, or your customers, can use.
What an interface is
Inside a workflow, an interface is a regular node: it needs at least one incoming edge and runs once its predecessors complete or are skipped. It has no ports of its own, it is a linear step in the graph. The page itself renders in a sandboxed iframe, separate from the rest of the app, and it does not inherit the app's styles or theme.
An interface does not have to live inside a workflow at all. You can create one standalone (a landing page, a calculator, a small game) and it works the same way, minus the variable and action mapping to a run. Your interfaces are listed on the Interfacespage, which is not shown in the sidebar by default: open it from the sidebar's overflow menu, or tick it in the customize panel behind that menu to pin it.
Two directions of data
An interface connects to the workflow through two small maps you configure on the node: variable mapping feeds workflow data into the page, and action mapping sends user input back out.
Variable mapping: workflow to page
Map a friendly, generic name to a workflow expression. In the HTML template you use {{name|default}}; in JavaScript you read the same data from a single resolved object.
"variableMapping": {
"userName": "{{mcp:fetch_user.output.name}}",
"results": "{{mcp:search.output.items}}"
}<h1>Hello {{userName|there}}</h1>
<div id="results"></div>
<!-- in js_template: -->
<script>const data = window.__RESOLVED_DATA__; /* data.results, data.userName */</script>Values are resolved when the page renders: a name that has no match falls back through a short chain, first the exact key, then the same key without its type: prefix, then a dotted drill into nested objects and arrays ({{photo.name}}, {{images[0]}}), then the pipe default, and finally a plain [label]placeholder as a last resort. A step that hasn't run yet (or failed) resolves the same way, through its default.
If a form on this same interface was already submitted once (in an earlier epoch of the same run), its fields pre-fill: matching name attributes on inputs, textareas and selects are populated with the previous submission automatically.
js_template and window.__RESOLVED_DATA__
Any <script> tag you write directly in the HTML template is stripped for security, and so is every inline event handler (onclick, onerror, and so on). All custom JavaScript goes in the node's js_template field, which is injected as its own script block and runs after the page renders:
(function () {
var data = window.__RESOLVED_DATA__;
var list = document.getElementById('results');
try {
(data.results || []).forEach(function (item) {
var el = document.createElement('div');
el.textContent = item.name;
list.appendChild(el);
});
} catch (e) {
// always guard JSON.parse / __RESOLVED_DATA__ access
}
})();window.__RESOLVED_DATA__holds every resolved variable, keyed by its friendly mapping name. Use it for loops, conditionals, and any DOM manipulation your template can't express with plain substitution. Your js_template script is injected last, after the platform's own scripts, so a thrown error in your JS never breaks them: the static HTML and the resolved variables are already rendered by that point.
Feeding a page from a Code step
A Code step's output is exposed downstream under an extra result key: whatever your code returned is read as {{core:<label>.output.result.<field>}}. If you map the whole output into an interface, the page receives that wrapper too.
Action mapping: page to workflow
Map a CSS selector to a single target key describing what should happen when the user interacts with that element.
"actionMapping": {
"#search-form": "trigger:search:submit",
"#chat-input": "trigger:chat:message",
"#next-btn": "__continue",
"#to-details": "interface:details:navigate",
"#next-page": "__pagination:next"
}| Target key | What it does |
|---|---|
trigger:label:submit | Binds the form submit event, collects every field with a name attribute, fires the trigger with that data. |
trigger:label:message | On a form, same as submit. On an input or textarea, Enter sends {message: value} and clears the field (Shift+Enter still inserts a newline). |
trigger:label:click | Binds a click event and sends the closest form’s data (or none, for a standalone button). |
interface:label:navigate | Switches the displayed page without touching the run. Frontend-only, no backend call. |
__continue | Resolves the interface’s signal and advances the workflow to the next node. |
__pagination:next|prev | Moves to the next or previous page of the application. Frontend-only. |
Only user-initiated triggers are legal action targets: manual, form, and chat. For __continue, the binding is a form's submit event when the mapped element is itself a <form>, otherwise it's a click; either way the element's closest form data (including files) is collected and sent along, and a standalone button with no surrounding form sends an empty payload.
Blocking vs just displaying
Whether an interface pauses the workflow is decided by exactly one thing: is __continue one of the action targets?
- With
__continue: the interface blocks. The node yields anINTERFACE_SIGNALand the run waits (AWAITING_SIGNAL) until the user clicks continue. This is how you build a wizard. - Without it: the interface just displays. Successors run immediately, and the run may reach
COMPLETEDwhile the page stays interactive, which suits a results page the user can keep re-submitting.
INTERFACE_SIGNAL is the only conditionally blocking signal in the engine, unlike a wait timer, a user approval, or a webhook wait, which always block. If a non-blocking interface still has un-run successors and its __continue fires after the run already completed, the run reopens (COMPLETED to RUNNING), executes the remaining successors, and re-finalizes: it stays RUNNING if a new blocking signal appeared, or returns to COMPLETED otherwise.
navigate vs __continue
| Aspect | __continue | navigate |
|---|---|---|
| Advances the workflow | Yes, backend call, resolves the signal | No, frontend-only |
| Target scope | Only triggers of this interface’s own DAG | Any interface in the workflow, doesn’t need to share a DAG |
| Use it for | Wizards, one step at a time | Tabs and multi-page apps that share state |
The Application view
When a run reaches an interface, the workflow's side panel gets an Application tab next to the workflow view: the workflow canvas is hidden and only the interface pages show, one after the other. Which page you see is decided like this:
- Page order follows the canvas from left to right: move an interface node on the canvas to move its page.
- First page: the interface with Entry Interface turned on (
isEntryInterface: true), otherwise the leftmost interface on the canvas. - While a run moves: the view follows the interface the run most recently reached (running or waiting for the user). Once the run is finished, it stays on the last interface that completed; it does not jump back to the entry page.
Building multi-page apps
- WizardChain interfaces in the DAG, each with a
__continueaction. Blocking keeps the runRUNNINGthroughout and advances one page at a time. - Tabs and multi-pageUse
navigateso pages share the same run state without advancing the workflow. - Carousel over a SplitPut an interface right after a Splitnode and it runs once per item: each item gets its own signal, and the page renders as a paginated carousel. If the interface blocks, every item's signal must resolve before the run advances past it.
- Fork and mergeSeveral interfaces in parallel behave as an implicit fork; a downstream merge waits for all of them (completed or skipped). Blocking interfaces keep the run going until every one's
__continuefires; non-blocking ones auto-advance independently.
Runs and pages are addressed as (epoch, spawn, item index) triples: each trigger fire starts a new epoch and previous results stay browsable, and a Split ahead of an interface adds the item index as extra pages.
Build a two-page app
This example shows the same data on two pages, a summary and a details page, and lets the user switch between them without re-running anything.
- Produce the dataIn a new workflow, add a Manual trigger followed by a Code step labelled
Loadthat returns the data your pages show. - Add the first pageConnect an interface labelled
HomeafterLoad. Map its data past the Code wrapper ("data": "{{core:load.output.result}}"), give it a button withid="to-details", map#to-detailstointerface:details:navigate, and turn on Entry Interface. - Add the second pageConnect a second interface labelled
DetailsafterLoadas well, and place it to the right ofHomeon the canvas. Map the same data, and map a#to-homebutton tointerface:home:navigate. - Run itRun the workflow and open the Application tab.
Homeopens first; the buttons switch pages instantly becausenavigatenever calls the backend. Neither page uses__continue, so the run completes and both pages stay interactive.
Rendering and authoring constraints
On top of the script stripping described above, every interface automatically gets a small set of injected system scripts: a height reporter so the parent can auto-size the iframe, a navigation gate, a broken-image fixer (broken <img> tags fall back to a transparent pixel to preserve layout), an optional auto-fit scaler, the action bridge, and the __RESOLVED_DATA__ injector. Links behave like this:
| Link type | Behavior |
|---|---|
| In-page anchor (#section) | Scrolls, allowed. |
| Scheme-less relative link | Does nothing: a single embedded page has nowhere to navigate to. |
| External (http/https/mailto/tel, or //host) | Gated: the viewer is asked to confirm, then it opens in a new tab. |
| javascript: or empty/# href | Blocked. |
| window.open() | Gated, allowed only under a genuine user gesture (a real click). |
Authoring tips that avoid the common layout gotchas:
- Include a fixed-width viewport tag, for example
<meta name="viewport" content="width=1280">, whose width matches the interface's format (see below; 1280 when no format is set). The host does not pass a real device width to the iframe, sowidth=device-widthmisrenders. - Set an explicit background and text color on
body: the page does not inherit the app's theme. - A fragment template (one that does not start with
<!DOCTYPE html>or<html>) gets a small base stylesheet and centering CSS onbodyin the app's previews, so wrap a full-width or top-aligned layout in a single wrapper<div>. A complete HTML document gets nothing injected and keeps its own body layout. - Design desktop-first, with responsive breakpoints around 1024px, 768px, and 480px.
- Google Fonts and Material Icons load fine through a normal
<link>tag.
Interface format
An interface can declare a Format: the shape it is designed for. The format sets the dimensions of every screenshot, video, and preview of that interface. Pick a preset or enter a custom WIDTHxHEIGHT (each side between 16 and 2160 pixels).
| Preset | Size (px) |
|---|---|
| classic | 1280 x 800 |
| widescreen | 1920 x 1080 |
| vertical | 1080 x 1920 |
| square | 1080 x 1080 |
| portrait | 1080 x 1350 |
| mobile | 390 x 844 |
| tablet | 820 x 1180 |
| desktop | 1440 x 900 |
| banner | 1500 x 500 |
| social_card | 1200 x 630 |
| a4_portrait | 794 x 1123 |
| a4_landscape | 1123 x 794 |
Leaving the format unset is not the same as classic: with no format, a screenshot captures the whole page at 1280 pixels wide however tall it is, while classic captures exactly 1280 x 800 and crops anything below. Leave it unset for a long dashboard or report you want captured whole.
Files & images in the page
When an interface renders, every file reference reachable through variable mapping is automatically converted into a usable URL wherever it lands: in <img src>, <a href>, <video src>, or in window.__RESOLVED_DATA__. Map the file under any friendly name, then reference that name in the HTML or iterate a list of them in js_template. Its .name, .mimeType, and .size are safe to drill; its raw storage .path is not. See Files & storage for the file object itself.
Files are scoped to your workspace: any member can view a file the page points to, and a different workspace is denied. Your access token is never placed in the page's HTML.
Uploading files from a form
An interface form can include <input type="file" name="photo"> like any other field. On submit, the file is uploaded and stored the same way any other file is. The next step reads it as a normal file reference under {{trigger:<label>.output.photo}} (a few flat sidecar fields, file URL, name, size, and content type, are also emitted alongside it for convenience).
Interface node outputs
Every interface node always outputs its interface_id, its action_mapping, whether it is_entry_interface, and its resolved resolved_params. Four more outputs are opt-in, each turned on with a switch on the node. They are best-effort: a capture failure leaves the output absent without failing the run.
| Switch | Output | What you get |
|---|---|---|
| Generate screenshot | screenshot | A PNG capture of the rendered page as a file, sized by the interface format, for a downstream step to attach, email, or store. |
| Generate PDF | pdf | A PDF export of the rendered page as a file. PDF page size is A4 (default) or Letter, with a Landscape option. The interface format does not apply to the PDF. |
| Generate video | video | An MP4 recording of the page’s animation. Recording stops when the page sets window.__DONE__ = true, or at the maximum duration (default 30 s, 5 to 120 s in the editor). Options: Video format (Auto uses the interface format, or Vertical, Horizontal, Square), Render mode (Smooth, frame by frame, the default; or Real-time), and Frame rate (24, 30 by default, or 60). |
| Expose rendered source | rendered_html / rendered_css / rendered_js | The page source as plain strings, each capped at 256K characters. Variables are filled in the HTML only; the CSS and JS come back exactly as written. |
Reading what the user submitted
Downstream steps read a submitted action with {{interface:<label>.output.<action_name>.<field>}}:
{{interface:my_form.output.submit.name}}
{{interface:my_form.output.submit.email}}Each submission is stamped with a fired_at timestamp automatically, and multiple submissions of the same action accumulate rather than overwrite one another. Firing a regular action (not __continue) returns immediately and the interface stays active and awaiting further input; only __continue resolves the blocking signal.
Editing an interface with the assistant
The assistant can create, read, list, update (replace a whole template), patch, and delete interfaces. A patch edits one template (HTML, CSS, or JS) with an ordered list of search and replace edits, applied all or nothing. After 10 consecutive successful patches on the same interface the assistant is told to stop and ask you what you want changed; an edit that fails to match writes nothing and does not count.
Applications
A workflow with interfaces becomes an application when you publish it to the marketplace. The Applications page lists the apps you published and the ones you installed from the marketplace. Like Interfaces, it is not shown in the sidebar by default: open it from the sidebar's overflow menu.
- Filter with All, Installed, or Published, and by visibility (Public, Private).
- Sort by Last executed, Recently added, or Name, mark favorites, and organize apps in folders.
- Open an app to use it full page, with its pages in the Application view.
- Select one published app to Update it (re-run the publish wizard) or create a Share link.
Share an application with a link
- Select the appOn the Applications page, select exactly one app you published.
- Create the linkClick Share link in the selection bar and copy the link.
- Manage it laterYour links are listed under Settings > Public Access (Applications tab) and in the Shared tab of the notification bell, where you can copy or revoke them.
Anyone with the link can open and use the app without an account. The app runs under your account, so treat the link like a key and revoke it when you no longer need it.
Publishing a standalone interface
A standalone interface can be published to the marketplace too, with a title and a visibility of private (default, not listed), unlisted (reachable by link, not listed), or public (goes through platform review before appearing). An interface is its own landing page, there is no separate listing page to create. You can charge credits per use, or leave it free.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The page shows its empty state on every run, with no error | The whole output of a Code step is mapped, so the data sits one level deeper, under result. | Map past the wrapper: {{core:<label>.output.result}}. See Feeding a page from a Code step. |
| Clicking a mapped button or link does nothing | The selector in action mapping matches no element in the rendered page (nor any data-action attribute), so the binding is skipped without an error. | Make the selector match your markup exactly, for example #next-btn for <button id="next-btn">. |
A submit action never fires | The selector points at the submit button. A submit binding listens on the element it matches, and only a <form> receives the submit event. | Map the form's own id, and keep the submit button inside that form. |
| A field is missing from the submitted data | Only fields with a name attribute are collected. An id alone is not enough. | Add name to every input, select, and textarea you want downstream. |
Code in a <script> tag or an onclick attribute never runs | Script tags and inline event handlers are removed from the HTML template for security. | Move the code to js_template and attach listeners there with addEventListener. |
| Later steps run before the user has done anything on the page | The interface has no __continue action, so it only displays and does not pause the run. | Map a button or form to __continue. See Blocking vs just displaying. |