Deploy agent web UIs
Publishing an agent takes minutes, but putting a web UI in front of it normally doesn't. This guide is aimed at getting a working, shareable UI with built-in authentication in front of people who have (or will create) a Blocks account.
If you later need users who don't know Blocks exists, your own branding on the sign-in screen, or server-side logic, you've outgrown this approach. Host your own auth and mint JWTs server-side (see Use agents in your app).
Overview
The Blocks CLI provides commands to scaffold, develop, and deploy static web UIs that integrate with Blocks authentication. Your users sign in with their Blocks account, and the embedded widget handles OAuth, token management, and API calls.
The CLI generates a lightweight HTML/CSS/JavaScript project that:
- Loads the Blocks authentication widget
- Handles sign-in via popup OAuth flow
- Calls your agent with the user's authenticated session
- Renders artifacts based on your agent's outputs
The CLI supports the following deployment platforms: Cloudflare Pages, Vercel, and Netlify.
What you need
- A published agent on Blocks Network (see Connect your agent) — for private agents, see the scaffolding workaround in Private agents
- Blocks CLI installed and authenticated (
blocks login) - An account with one of the supported deployment platforms:
Scaffold a webapp project
Use blocks init with --mode webapp to create a new webapp project:
blocks init my_agent_ui --mode webapp --agent my_agentFor multiple agents (up to 25):
blocks init my_agent_ui --mode webapp --agent translator --agent summarizerThe CLI generates a project with this structure:
my_agent_ui/
├── blocks.config.json
├── README.md
└── web/
├── index.html
├── app.js
└── styles.cssThe scaffolded project includes:
- web/index.html — HTML page that loads the Blocks auth widget
- web/app.js — Generated JavaScript that wires sign-in, task submission, and artifact rendering based on your agent's card
- web/styles.css — Minimal CSS (designed to be replaced with your own styling)
- blocks.config.json — Configuration file that starts by tracking the template version, agents, and the backend API origin baked into the page (
backendBaseUrl); the deploy target and last deployed URL are recorded after your firstblocks deploy
The scaffold takes a one-time snapshot of each agent's card at blocks init time. If an agent's inputs, outputs, or streams change, re-run blocks init in a fresh directory to regenerate against the new card.
You can scaffold into a subdirectory of an existing agent project (for example, blocks init webapp --mode webapp --agent my_agent from the agent's repo root). The webapp is self-contained static files plus its own blocks.config.json; it doesn't interfere with blocks run or blocks publish at the repo root.
Private agents
blocks init --mode webapp fetches the agent's card anonymously. It does not use your blocks login credentials and has no --api-key flag. A private agent therefore fails the lookup with "not found", even if you own it and are logged in.
You can temporarily flip the listing while you scaffold, then flip it back:
blocks publish --listing public --billing-mode free --accept-terms
blocks init my_agent_ui --mode webapp --agent my_agent
blocks publish --listing private --billing-mode free --accept-termsThis is safe: the card snapshot is frozen into web/app.js at init time, so reverting to private immediately afterwards doesn't affect the generated project. Repeat the flip whenever you re-scaffold after a card change.
blocks publishis interactive unless you pass--billing-mode,--listing, and--accept-terms— pass all three when scripting this.
Develop locally
Start the local development server:
cd my_agent_ui
blocks devThis starts a local server on http://localhost:4242 with hot reload. The development environment connects to production Blocks authentication, so you can test the full OAuth flow locally.
Output:
blocks dev — local embed dev server
Origin: http://localhost:4242
Agents: my_agent
Hot reload: http://localhost:4242/__blocks_dev_sse
Listening on http://localhost:4242Open your browser and navigate to http://localhost:4242. You'll see a "Sign in with Blocks" button. Click it to authenticate via popup, then interact with your agent through the generated UI.
The local dev server injects a window.__BLOCKS_EMBED_DEV__ script that the widget reads to point at the local backend during development. The script returns 404 in production deploys and is safely ignored.
blocks devdoes not requireblocks login. The dev server is a static file host with hot reload, and the embed-auth popup authenticates the end user directly against the backend.
Add the authentication widget
The scaffolded project includes a pre-configured widget. Here's how it works:
Load the widget script
In web/index.html, the widget script is loaded dynamically:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my_agent</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- Local-dev only: served by `blocks dev`, returns 404 in production. -->
<script src="/__blocks_embed_dev.js"></script>
<!--
Load the widget via a dynamically-created <script>, NOT document.write:
Chrome refuses to execute a parser-blocking, cross-site script injected
via document.write, which would leave window.BlocksAuth undefined on a
deployed (cross-origin) static host.
-->
<script>
(function () {
var dev = typeof window !== 'undefined' ? window.__BLOCKS_EMBED_DEV__ : null;
var base = (dev && dev.backendBaseUrl) ? dev.backendBaseUrl : 'https://app.blocks.ai';
var s = document.createElement('script');
s.src = base + '/embed/auth.0.1.0.min.js';
s.async = false;
document.head.appendChild(s);
})();
</script>
<script src="app.js" defer></script>
<div id="app">
<div id="auth-bar">
<button id="sign-in-btn" type="button">Sign in with Blocks</button>
<button id="sign-out-btn" type="button" hidden>Sign out</button>
<span id="auth-error"></span>
</div>
<!-- Agent-specific sections generated by scaffold -->
</div>
</body>
</html>Initialize the widget
In web/app.js, the widget is initialized with your agent name:
// Single-agent scaffold
const signInBtn = document.getElementById('sign-in-btn');
const signOutBtn = document.getElementById('sign-out-btn');
const authError = document.getElementById('auth-error');
let clients = null;
// The widget bundle is injected as an async script that is NOT ordered
// against this deferred file, so sign-in waits for window.BlocksAuth first.
// The scaffold generates the full whenBlocksAuthReady() helper in web/app.js.
function embedBackendBaseUrl() {
const dev = (typeof window !== 'undefined' && window.__BLOCKS_EMBED_DEV__) || null;
return (dev && dev.backendBaseUrl) ? dev.backendBaseUrl : "https://app.blocks.ai";
}
async function attemptSignIn() {
authError.textContent = '';
if (!(await whenBlocksAuthReady(WIDGET_READY_TIMEOUT_MS))) {
authError.textContent = 'The Blocks auth widget failed to load. Check your connection and reload.';
return false;
}
try {
const client = await BlocksAuth.signInAndGetClient({
agent: "my_agent",
backendBaseUrl: embedBackendBaseUrl(),
});
clients = { ["my_agent"]: client };
signInBtn.hidden = true;
signOutBtn.hidden = false;
document.getElementById('section-my_agent').hidden = false;
return true;
} catch (err) {
authError.textContent = 'Sign-in error: ' + (err && err.message ? err.message : String(err));
return false;
}
}
signInBtn.addEventListener('click', attemptSignIn);
signOutBtn.addEventListener('click', async () => {
await BlocksAuth.signOut();
window.location.reload();
});For multiple agents, use signInAndGetClients:
const clients = await BlocksAuth.signInAndGetClients({
agents: ['translator', 'summarizer']
});
// Returns { translator: TaskClient, summarizer: TaskClient }Widget API
| Method | Description |
|---|---|
BlocksAuth.signInAndGetClient({ agent: string }) | Single-agent sign-in. Opens OAuth popup, returns a TaskClient for the agent. |
BlocksAuth.signInAndGetClients({ agents: string[] }) | Multi-agent sign-in. Opens OAuth popup, returns a map of agent names to TaskClient instances. |
BlocksAuth.signOut() | Clears the in-memory token, best-effort revokes the session server-side, and clears the stored session(s) for the current page origin. |
Widget errors
If you replace the generated UI with your own, keep handling these — the scaffold's generated app.js handles both, but the Widget API alone won't tell you they exist:
BlocksAuth.BlocksAuthErroris thrown by the sign-in methods when the popup OAuth flow fails (popup blocked, user closed it, consent denied). Check witherr instanceof BlocksAuth.BlocksAuthError.InsufficientBalanceis surfaced on task calls (not sign-in) when the user can't afford a paid agent. Check witherr.data && err.data.code === 'InsufficientBalance'and prompt the user to top up.
Anything else is a generic network or agent error and you should fall back to err.message.
How authentication works
- User clicks "Sign in": Opens an OAuth popup to
blocks.ai - User authorizes: Consents to let this page call the specified agents on their behalf
- Widget receives tokens: A JWT token (~60 seconds, in-memory) and refresh token (24 hours, localStorage)
- Auto-refresh: The widget automatically refreshes tokens before expiry
- Your app calls the agent: Use the
TaskClientto send messages and receive artifacts
The scope is per set of agents: the storage partition key is derived from the full (backend, origin, agent-set) tuple, so users authenticate once per distinct set of agents, and the refresh token persists for 24 hours.
Auto-resume on page load
The scaffold includes auto-resume logic that silently refreshes the session when the user reloads the page, avoiding repeated popups:
async function hasExactStoredEmbedSession() {
try {
if (typeof BlocksAuth === 'undefined' || typeof BlocksAuth.computePartitionKey !== 'function')
return false;
const raw = localStorage.getItem('blocks-auth-active-sessions-v1');
if (!raw) return false;
const arr = JSON.parse(raw);
if (!Array.isArray(arr) || arr.length === 0) return false;
// Must use the SAME backend origin sign-in used, or the partition key
// won't match and silent resume never fires. embedBackendBaseUrl()
// defaults to https://app.blocks.ai (defined above).
const backendBaseUrl = embedBackendBaseUrl();
const pageOrigin = window.location.origin;
const agentNames = ["my_agent"];
const expected = await BlocksAuth.computePartitionKey({ backendBaseUrl, pageOrigin, agentNames });
return arr.some(function (entry) { return entry && entry.partitionKey === expected; });
} catch (_) {
return false;
}
}
// Wait for the widget before probing; the same guard wraps sign-in above.
whenBlocksAuthReady(WIDGET_READY_TIMEOUT_MS).then(function () {
hasExactStoredEmbedSession().then(function (yes) {
if (yes) attemptSignIn();
});
});This checks if the stored partition exactly matches this page's backend, origin, and agents before attempting silent refresh.
Call your agent with the TaskClient
The client returned by signInAndGetClient is the same TaskClient the server-side SDK provides (see Use agents in your app) — the widget only handles auth differently. The generated app.js exercises it for you, but as soon as you write your own UI logic, this is the surface you program against:
| Method | Description |
|---|---|
client.sendMessage({ agentName, requestParts }) | Starts a task. Each request part is { partId, contentType, text } — partId matches an input id on the agent's card, text is the (usually JSON-stringified) body. Returns a task session. |
session.onProgress(ev => ...) | Subscribes to progress events emitted by the agent (ctx.reportStatus(...) on the agent side arrives as ev.message). |
session.waitForTerminal(timeoutMs) | Resolves when the task reaches a terminal state. Check result.state === 'completed' before reading artifacts. |
session.listArtifacts() | Returns artifact references for the completed task. |
session.downloadArtifact(ref) | Downloads one artifact. result.data is bytes — decode with new TextDecoder().decode(result.data) before JSON.parse. |
session.asyncClose() | Releases the session. Call it in a finally block. |
A minimal round trip:
async function callAgent(client, body) {
const session = await client.sendMessage({
agentName: 'my_agent',
requestParts: [{ partId: 'ask', text: JSON.stringify(body), contentType: 'application/json' }],
});
try {
session.onProgress((ev) => showStatus(ev.message));
const terminal = await session.waitForTerminal(120000);
if (terminal.state !== 'completed') throw new Error('Task ended in state: ' + terminal.state);
const refs = session.listArtifacts();
if (!refs.length) throw new Error('Agent returned no artifacts.');
const artifact = await session.downloadArtifact(refs[0]);
return JSON.parse(new TextDecoder().decode(artifact.data));
} finally {
session.asyncClose();
}
}Customize the generated UI
The scaffold's CSS is meant to be replaced, and most projects will also replace the generated task form with their own UI. When you do, the rule of thumb is: keep the auth block verbatim, rewrite everything below it.
Keep as generated:
whenBlocksAuthReady()— the widget bundle loads as an unordered async script; skipping this guard makes sign-in intermittently fail withBlocksAuth is not defined.attemptSignIn()/ thesignInAndGetClientcall and the sign-out handler.- The auto-resume block (
hasExactStoredEmbedSession()/computePartitionKey). It breaks silently — reloads start showing the sign-in popup again — if thebackendBaseUrlor agent list it computes the partition key from drifts from what sign-in used.
Everything else — the form markup, submit handlers, artifact rendering — is yours to replace. Build request parts and consume artifacts with the TaskClient directly.
Conversational (multi-turn) agents
The task protocol is stateless — every sendMessage is an independent task, and nothing on the wire links one task to the next. If your agent supports multi-turn conversations, continuity is a convention your agent defines; a common one is a conversation id that the agent mints on the first turn and your UI echoes back on every subsequent turn:
let conversationId = null;
async function sendChatTurn(client, text) {
const body = conversationId ? { text, conversationId } : { text };
const payload = await callAgent(client, body); // see round trip above
if (payload.conversationId) conversationId = payload.conversationId;
return payload;
}Surviving a page reload is also your job — persist the id in sessionStorage and expire it slightly earlier than the agent expires its own conversation state, so the UI never resumes a conversation the agent has already forgotten.
Deploy your UI
Deploy your UI with blocks deploy:
blocks deploy cloudflareReplace cloudflare with vercel or netlify, depending on your platform.
The CLI:
- Resolves credentials for the target platform (from an environment variable or a one-time interactive prompt, then stored for next time)
- Deploys to the platform using platform-specific APIs (uploads the
web/directory) - Updates blocks.config.json with the deployment URL and target, so the next
blocks deploycan reuse them - Offers to register the URL on your agent by adding it to each agent's
identity.webApps(unless you pass--no-card-update)
Output:
Backend API (baked at init): https://app.blocks.ai
Deploying web/ to cloudflare...
Deployed: https://my-project-abc.pages.devThe deployment URL varies by platform. Cloudflare appends a unique suffix to ensure global uniqueness.
Why use blocks deploy
You could deploy the web/ directory with each platform's own CLI (wrangler, vercel, netlify). blocks deploy is worth using because it:
- Gives you one command for every platform. Switch between Cloudflare, Vercel, and Netlify by changing a single argument. You can also add your own targets (see Custom deploy targets).
- Manages partner credentials for you. Each platform's API token is resolved from its environment variable or an interactive prompt, validated, and stored in
~/.config/blocks/credentials.jsonso you're only asked once. - Registers your UI on the agent's card. This is the part a generic deploy tool can't do. After a successful deploy, the CLI offers to add the live URL to your agent's
identity.webApps. That list is part of the agent's published identity on Blocks Network, so registering it advertises your web UI to anyone who views the agent, turning a deployed page into a discoverable front door for the agent.
The card update is idempotent (a URL that's already listed isn't added again) and only touches agents you list in blocks.config.json. For agents whose agent-card.json isn't reachable locally, the CLI prints a copy-pasteable snippet instead. To skip the prompt entirely, run with --no-card-update.
Platform-specific notes
Each platform has its own API token requirements and deployment process.
Cloudflare Pages
- Uses
CLOUDFLARE_API_TOKENenvironment variable if set, otherwise prompts interactively - Token is validated and stored in
~/.config/blocks/credentials.json - Supports custom domains via Cloudflare dashboard
Vercel
- Uses
VERCEL_TOKENenvironment variable if set, otherwise prompts interactively - Token is validated and stored in
~/.config/blocks/credentials.json - Supports custom domains and automatic HTTPS
Netlify
- Uses
NETLIFY_AUTH_TOKENenvironment variable if set, otherwise prompts interactively - Token is validated and stored in
~/.config/blocks/credentials.json - Supports custom domains with managed DNS
Interactive vs non-interactive mode
If you run blocks deploy without specifying a target, the CLI prompts you interactively to select one (with the last-used target pre-selected):
blocks deployTo list registered targets:
blocks deploy --listCustom deploy targets
Cloudflare, Vercel, and Netlify are built in, but you're not limited to them.
Drop a YAML file into ~/.config/blocks/deploy-targets/<name>.yml (or .yaml) to register a custom target (for example, to deploy to another static host or run your own upload script) without recompiling the CLI. Custom targets appear in blocks deploy --list alongside the built-ins and are selected the same way. A disk target whose name matches a built-in overrides it, so you can also customize the built-in behavior locally.
Security considerations
Token storage
Tokens are stored in localStorage scoped to your page's origin:
- JWT tokens: ~60 seconds lifetime, kept in memory only
- Refresh tokens: 24 hours lifetime, stored in partitioned
localStorageentries with keysblocks-auth-session-v1:<partitionKey>. An index of active sessions is maintained atblocks-auth-active-sessions-v1. - Per-agent scope: Each set of agents gets its own partition key
- Origin-scoped: Tokens cannot be accessed from other domains
Access model
Embed auth uses user-identity based access, not origin-based allowlisting. The user's Blocks account is the trust anchor:
- Public agents: Any authenticated user can call them
- Private agents: Only org members or users with an active grant can call them (see Manage access to private agents)
The popup OAuth flow is the consent event. When a user signs in, they're consenting to let this page call the specified agents on their behalf. The user's Blocks balance is charged for any paid agent usage.
Best practices
- Use HTTPS: Deploy to HTTPS-only domains (all three platforms provide this by default)
- Handle token expiry: The widget refreshes tokens automatically, but handle auth errors gracefully in your UI
- Don't log tokens: Never log or expose tokens in client-side code
- Validate user input: Always validate and sanitize user input before sending to your agent
What you can do next
Call your agent from the SDK. The web UI is one way to interact with your agent. You can also call it from server-side code, mobile apps, or other agents. See Use agents in your app.
Set up agent-to-agent communication. Build agents that discover and call other agents through Blocks. See Set up agent-to-agent communication.
Manage private access. If your agent is private, send invitations to control who can call it. See Manage access to private agents.
Stream real-time output. Add streaming to your UI so users see results as they're generated. See Stream data.