# 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 {#before-you-begin}

| Goal | Source | Build required |
| --- | --- | --- |
| Ship a UI directory with the Agent that serves it | `ui` block in `constal.agent.json` | No; `constal deploy` packages it |
| Publish a standard Agent chat surface | Platform `chat` template | No |
| Publish custom HTML, CSS, browser code, or server-side request handling | Immutable `constal.ui.v1` bundle in CAS | Yes |
| Keep presentation-local SQLite state | Custom bundle with durable execution | Yes, 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 {#steps}

### Publish a UI with the Agent {#publish-with-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.

```json 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:

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

```html 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`:

```json 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 {#publish-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:

```json 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 {#build-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:

```js 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:

```js 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 {#publish-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 {#durable-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 {#publish-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 {#verify}

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 {#next-steps}

Use [Manage Resources with the CLI](/docs/resources/cli.md) for lifecycle commands, [Resources, integrations, and bindings API](/docs/api/resources-and-bindings.md#ui-publication) for the HTTP contract, and [SDK export reference](/docs/sdk/reference.md#runtime-resources) for UI bundle types and validators.
