Build and publish Dynamic UIs

Build a custom Agent-facing web interface or publish the platform chat template through the normal Resource API.

A UI is a first-class, non-invokable Resource that gives one exact Channel and Agent revision a stable public URL. Publishing a UI is ordinary resource.create with kind: "ui"; it does not require a separate service deployment or introduce a second publication API.

Before you begin

GoalSourceBuild required
Ship a UI directory with the Agent that serves itui block in constal.agent.jsonNo; constal deploy packages it
Publish a standard Agent chat surfacePlatform chat templateNo
Publish custom HTML, CSS, browser code, or server-side request handlingImmutable constal.ui.v1 bundle in CASYes
Keep presentation-local SQLite stateCustom bundle with durable executionYes, including a synchronous migration hook

For a UI declared in the Agent manifest, deploy the Channel first; the deployment pins the Agent revision it just built. For every standalone publication, deploy the Agent and Channel first. Record their exact CRNs and current hashes. They must be enabled, belong to the UI's tenant and namespace, and the Agent must match the Channel's accepted selector. An authenticated UI also pins one enabled Auth Provider revision. A custom bundle additionally pins one readable CAS Resource revision.

Steps

Publish a UI with the Agent

Declare the UI inside constal.agent.json when the interface is authored next to the Agent. constal deploy . builds the Agent, packages the declared directory as a constal.ui.v1 bundle inside the build sandbox, stores it as an immutable artifact, and publishes a ui Resource whose target.agent is exactly the Agent revision produced by that deployment. Both current pointers move in one transaction: the Agent and its UI are promoted together or not at all.

constal.agent.json
{
  "schemaVersion": 2,
  "kind": "agent",
  "id": "support",
  "namespace": "default",
  "entry": "src/index.ts",
  "labels": { "channels.constal.ai/openai": "enabled" },
  "ui": {
    "id": "support-workspace",
    "displayName": "Support workspace",
    "description": "Chat with the Support Agent.",
    "source": "ui",
    "channel": { "kind": "local", "resourceKind": "channel", "id": "openai-chat-completions" },
    "access": { "mode": "public" },
    "execution": { "mode": "stateless" },
    "labels": { "app.constal.ai/primary": "true" }
  }
}

source is a directory inside the project. Its entry module and static assets follow the same layout a custom bundle uses:

ui/worker.mjs
export default {
  async fetch(request, context) {
    return context.assets.fetch(request);
  }
};
ui/public/index.html
<!doctype html>
<title>Support workspace</title>
<script type="module">
  const response = await fetch("/_constal/channel", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] })
  });
  document.body.textContent = JSON.stringify(await response.json());
</script>

The bundle contains the entry module plus every module it reaches through relative .mjs imports, and every file under the assets root. Bundle manifest fields default to entry worker.mjs, assets root public with fallback index.html, and a strict content security policy. Override them with an optional ui/constal.ui.json; a durable UI whose SQLite schema moves past version 1 must ship it with the bumped state:

ui/constal.ui.json
{
  "runtime": { "entry": "worker.mjs" },
  "assets": { "root": "public", "fallback": "index.html" },
  "state": { "schemaVersion": 2, "compatibleSchemaVersions": { "minimum": 1, "maximum": 2 } }
}

The ui block owns the Resource fields: channel and access.authProvider accept same-namespace local references or CRNs, access defaults to public, execution to stateless, and limits to the platform UI limits. The Agent must carry the label the Channel's target selector requires. Deploy and read the published URL from the deployment record:

sh
constal deploy . --wait --output json \
  | jq -r '.outputs[] | select(.resourceKind == "ui") | .resource.url'

The CLI always archives the declared directory, even when .constalignore lists it, and fails before uploading when the directory is missing, reserved, or contains another Agent manifest. The UI ships inside the Agent archive, so the compressed upload must stay under the 10 MiB deployment intake limit; larger UIs use the standalone path below. Inline UIs support only immediate rollouts, so --rollout canary and candidate builds are rejected. Redeploying the Agent publishes a new UI revision pinned to the new Agent revision while the route identifier and URL stay stable. Removing the block does not delete the UI; delete it explicitly with constal resources delete ui support-workspace.

Publish the chat template

The remaining steps publish a standalone UI through the Resource API. Use them when the interface is not authored in an Agent manifest, exceeds the archive ceiling, or must target a canary.

In the Console, open UIs, choose Publish UI, select Chat template v1, and choose the exact Channel and Agent. Set access, Policies, and request limits, then publish. The response includes the immutable UI hash and its stable https://ui-….constal.dev/ URL.

The same operation is available through the CLI and Platform API. Save a reviewed definition such as:

support-chat.json
{
  "kind": "ui",
  "id": "support-chat",
  "version": "1",
  "displayName": "Support chat",
  "description": "Chat with the Support Agent.",
  "source": { "kind": "template", "template": "chat", "version": "1" },
  "target": {
    "channel": {
      "crn": "crn:constal:production:YOUR_TENANT:default:channel/openai-chat-completions",
      "hash": "CHANNEL_REVISION_HASH"
    },
    "agent": {
      "crn": "crn:constal:production:YOUR_TENANT:default:agent/support",
      "hash": "AGENT_REVISION_HASH"
    }
  },
  "access": { "mode": "public" },
  "execution": { "mode": "stateless" },
  "limits": {
    "requestBodyBytes": 65536,
    "responseBodyBytes": 1048576,
    "cpuMs": 1000,
    "subrequests": 8
  },
  "policies": [],
  "expectedCurrentHash": null
}

Publish and read it back:

sh
constal resources create --body @support-chat.json --output json
constal resources get ui support-chat --output json

For authenticated access, replace access with an exact Auth Provider pin:

json
{
  "mode": "authenticated",
  "authProvider": {
    "crn": "crn:constal:production:YOUR_TENANT:default:auth-provider/browser-login",
    "hash": "AUTH_PROVIDER_REVISION_HASH"
  }
}

Build a custom bundle

A bundle is canonical JSON containing final runnable ESM and base64-encoded assets. It is not a ZIP file, source package, standalone service package, or place to install dependencies at request time. Compile TypeScript and bundle dependencies before creating this object.

The entry module directly exports a constal.ui-handler.v1 object. A static application can delegate ordinary routes to its pinned asset capability:

worker.mjs
export default {
  async fetch(request, context) {
    return context.assets.fetch(request);
  }
};

Browser code calls /_constal/channel on the UI's own origin to reach the pinned Channel and Agent. Server-side handler code can call context.channel.fetch(). Neither path exposes a tenant key or Credential to the bundle.

Use uiBundle() to validate and normalize the artifact before storing it. hashValue() computes the same canonical hashes the platform verifies:

build-ui.mjs
import { readFile, writeFile } from "node:fs/promises";
import { hashValue, uiBundle } from "@constal/sdk";

const manifest = {
  schemaVersion: 1,
  kind: "constal.ui",
  runtime: { contract: "constal.ui-handler.v1", entry: "worker.mjs" },
  assets: { root: "public", fallback: "index.html" },
  contentSecurityPolicy: "strict"
};

const candidate = {
  manifest,
  modules: {
    "worker.mjs": {
      type: "esmodule",
      source: await readFile("dist/worker.mjs", "utf8")
    }
  },
  assets: {
    "public/index.html": {
      bodyBase64: (await readFile("dist/index.html")).toString("base64"),
      contentType: "text/html; charset=utf-8"
    }
  }
};

const bundle = uiBundle(candidate, "stateless");
const ref = await hashValue(bundle);
const manifestHash = await hashValue(bundle.manifest);
await writeFile("ui.bundle.json", JSON.stringify(bundle));
console.log(JSON.stringify({ ref, manifestHash }, null, 2));

Run the build locally or in a governed Sandbox. Store the normalized object through an exact CAS Resource's idempotent put operation—not putText—and retain its returned ref. A build Agent can perform the final step without receiving storage credentials:

ts
import { hashValue, uiBundle } from "@constal/sdk";

const bundle = uiBundle(JSON.parse(builtBundleText), "stateless");
const stored = await ctx.invoke<{ ref: string }>(
  ctx.resources.ui_artifacts!,
  "put",
  { value: bundle },
  { dedupeKey: `ui-bundle:${await hashValue(bundle)}` },
);

return {
  cas: ctx.resources.ui_artifacts,
  ref: stored.ref,
  manifestHash: await hashValue(bundle.manifest),
};

The CAS ref must equal hashValue(bundle). The CAS Resource, bundle ref, and manifest hash are all immutable publication inputs. The public Platform API intentionally receives those references rather than raw storage credentials.

Publish the custom bundle

Use the same Resource definition as the template example, but replace source with the exact artifact returned by the build and CAS steps:

json
{
  "kind": "bundle",
  "artifact": {
    "cas": {
      "crn": "crn:constal:production:platform:default:cas/constal",
      "hash": "CAS_RESOURCE_REVISION_HASH"
    },
    "ref": "BUNDLE_CONTENT_HASH",
    "manifestHash": "MANIFEST_CONTENT_HASH",
    "format": "constal.ui.v1"
  }
}

Set that object as source, keep execution.mode as stateless, and submit it with constal resources create --body @custom-ui.json. The platform resolves the exact CAS revision, verifies both hashes, validates the module graph and handler export, checks target and Policy relationships, creates the immutable UI revision, and returns its stable URL.

There is deliberately no separate ui publish command. Automation may call the underlying endpoint directly:

sh
curl https://platform.constal.ai/v1/namespaces/default/resources \
  -H "Authorization: Bearer $CONSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @custom-ui.json

Add durable SQLite state

Choose durable execution only for presentation-local coordination or state. Add a state contract to the bundle manifest:

json
{
  "state": {
    "schemaVersion": 1,
    "compatibleSchemaVersions": { "minimum": 0, "maximum": 1 }
  }
}

The entry must also export a synchronous migrate function. It receives only the bounded state interface:

js
export default {
  migrate({ from }, { state }) {
    if (from === null) {
      state.query("CREATE TABLE preferences (subject TEXT PRIMARY KEY, value TEXT NOT NULL)");
    }
  },
  async fetch(request, context) {
    return context.assets.fetch(request);
  }
};

Publish with finite storage limits:

json
{
  "mode": "durable",
  "storage": {
    "kind": "sqlite",
    "maximumBytes": 10485760,
    "maximumRowsReadPerRequest": 10000,
    "maximumRowsWrittenPerRequest": 1000,
    "maximumResultBytes": 262144
  }
}

The UI retains its database across compatible revisions. Schema versions cannot decrease, and a new bundle must declare compatibility with the stored version. Use an Agent's governed database Resource instead when data must scale beyond one UI's coordination boundary.

Publish an update

For a UI declared in the Agent manifest, redeploying the project is the update path: the deployment reads the current UI hash, publishes the new revision only if that hash remains current, and moves both pointers together. A standalone publication may replace an inline-published UI; the next constal deploy re-pins it.

For a standalone UI, read the current UI and copy its hash. Change the version and any source, target, access, Policy, label, execution, or limit fields, then set expectedCurrentHash to that exact current hash and submit another resources create request. A stale hash fails instead of overwriting a concurrent revision.

The UI CRN and opaque URL remain stable; only the current immutable revision moves. Requests already admitted stay pinned to their accepted revision. To stop new traffic without changing the revision:

sh
constal resources disable ui support-chat \
  --event-id support-chat-maintenance-1 \
  --reason "maintenance"

constal resources enable ui support-chat \
  --event-id support-chat-maintenance-complete-1 \
  --reason "validated replacement"

Deletion tombstones the opaque route so it cannot be reassigned. Prefer disable when the intent is to unpublish temporarily.

Verify and troubleshoot

Read the UI after publication and confirm its CRN, hash, URL, exact Agent and Channel pins, access mode, source hashes, execution mode, Policies, and control state. Open the returned URL in an isolated browser context and send one low-risk request.

Common publication failures are actionable:

  • bundle unavailable or hash mismatch: store canonical JSON with CAS put, then copy the returned ref and the SDK-computed manifest hash;
  • Agent does not match Channel selector: update the Agent label or choose a compatible Channel before publishing;
  • dependency outside UI scope: use same-tenant, same-namespace Agent, Channel, and Auth Provider revisions; only the artifact CAS may be the tenant-scoped platform CAS;
  • invalid handler or module graph: publish final ESM, use one direct default handler object, and include every relative import in modules;
  • durable schema incompatible: publish a forward-compatible bundle and synchronous migration rather than decreasing the stored schema version.

Never put secrets, .env files, API keys, storage bindings, or arbitrary network destinations in a UI bundle. Authored code receives only assets, the exact UI identity, the pinned Channel capability, and—when selected—the bounded SQLite state capability.

Next steps

Use Manage Resources with the CLI for lifecycle commands, Resources, integrations, and bindings API for the HTTP contract, and SDK export reference for UI bundle types and validators.