Everything you need to build, test, and publish apps for Construct — the AI-powered virtual desktop.
- What is a Construct App?
- Quick Start
- Project Structure
- manifest.json Reference
- Building Your MCP Server
- Using the App SDK
- Calling Platform Tools
- Adding a Visual UI
- Construct Browser SDK
- Authentication (OAuth2, API Key, Bearer, Basic)
- Testing Locally
- Publishing to the Registry
- Updating Your App
- How Publishing Works Internally
- Categories
- API Reference
- Troubleshooting
A Construct app is a small server that exposes tools via the Model Context Protocol (MCP) — a JSON-RPC 2.0 protocol. When a user installs your app, the Construct agent can call your tools to help the user accomplish tasks.
Apps can optionally include a visual UI that opens in a sandboxed window on the Construct desktop, allowing users to interact with your app directly.
Two types of Construct apps:
| Type | Description | Example |
|---|---|---|
| Tools-only | MCP server with no visual UI. The agent calls your tools directly. | MercadoLibre, currency converter |
| With UI | MCP server + an HTML interface that users can interact with in a desktop window. | DevTools, calculator, notes |
Key concepts:
- Your app server handles
POST /mcprequests using the MCP JSON-RPC protocol - Three MCP methods:
initialize,tools/list, andtools/call - Apps run on Cloudflare Workers — they're bundled into the registry worker during deployment
- If your app has a UI, it loads in an iframe and communicates with the Construct platform via the
window.constructJavaScript SDK
The fastest way to create a new Construct app is to use the template repo:
- Click "Use this template" on GitHub (or clone it directly)
- Rename and customize for your app
git clone https://github.com/construct-computer/construct-app-sample.git my-app
cd my-app
pnpm install
pnpm devYour app is now running at http://localhost:8787. Test it:
# Health check
curl http://localhost:8787/health
# List available tools
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
# Call a tool
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"uuid","arguments":{"count":3}},"id":2}'The template includes a working MCP server with nine example tools and a visual UI. Strip out what you don't need and add your own tools.
my-app/
├── manifest.json # App metadata — name, description, icon, categories
├── server.ts # MCP server — registers tools, handles requests
├── icon.png # App icon, 256×256 (or icon.svg)
├── package.json # Dependencies and scripts
├── wrangler.toml # Cloudflare Workers config for local dev
├── tsconfig.json # TypeScript config
├── .gitignore
├── README.md
└── ui/ # OPTIONAL — Visual interface
├── index.html # UI entry point
└── construct.d.ts # TypeScript types for the SDK globals
Tools-only app (no ui/ directory):
my-app/
├── manifest.json # No "ui" field
├── server.ts
├── icon.png
├── package.json
└── wrangler.toml
App with UI (includes ui/ directory):
my-app/
├── manifest.json # Has "ui" field with window dimensions
├── server.ts
├── icon.png
├── package.json
├── wrangler.toml
└── ui/
├── index.html # Loads construct.js + construct.css SDK
└── construct.d.ts
| File | Required | Description |
|---|---|---|
manifest.json |
Yes | App metadata for the store listing |
server.ts (or src/index.ts or index.ts) |
Yes | MCP server entry point |
icon.png (or .svg/.jpg) |
Yes | App icon, 256×256 recommended |
README.md |
Yes | Shown as the store description |
ui/index.html |
No | Visual interface (omit for tools-only apps) |
The manifest declares your app's metadata. The shape is described by the JSON Schema at https://raw.githubusercontent.com/construct-computer/app-sdk/main/schemas/manifest.schema.json — add it as $schema for autocomplete + inline validation in VS Code and other editors. The registry's CI re-checks required fields at PR time.
{
"$schema": "https://raw.githubusercontent.com/construct-computer/app-sdk/main/schemas/manifest.schema.json",
"name": "My App",
"description": "A short one-line description of what your app does."
}{
"$schema": "https://raw.githubusercontent.com/construct-computer/app-sdk/main/schemas/manifest.schema.json",
"name": "My App",
"description": "A short one-line description of what your app does.",
"author": { "name": "Your Name", "url": "https://github.com/your-username" },
"owners": ["your-github-login"],
"icon": "icon.png",
"categories": ["utilities"],
"tags": ["example", "demo"],
"ui": {
"entry": "ui/index.html",
"width": 800,
"height": 600
},
"auth": {
"schemes": [
{
"type": "oauth2",
"label": "Sign in with Example",
"authorization_url": "https://api.example.com/oauth/authorize",
"token_url": "https://api.example.com/oauth/token",
"scopes": ["read", "write"]
},
{
"type": "api_key",
"label": "Use API Key",
"instructions": "Get your key at https://api.example.com/settings/keys",
"fields": [
{ "name": "api_key", "displayName": "API Key", "type": "password", "required": true }
]
}
]
},
"permissions": {
"network": ["api.example.com"],
"storage": "1MB",
"uses": {
"tools": ["drive.list_files", "calendar.list_events"],
"apps": [
{ "app_id": "sample-summarizer", "tools": ["summarize"] }
]
}
},
"tools": [
{ "name": "search", "description": "Search for items" }
]
}| Field | Type | Required | Description |
|---|---|---|---|
$schema |
string | No | JSON Schema URL for IDE validation. Always include https://raw.githubusercontent.com/construct-computer/app-sdk/main/schemas/manifest.schema.json. |
name |
string | Yes | Display name. Shown in the App Store and Launchpad. |
description |
string | Yes | Short description. Shown in search results and app cards. |
author |
object | No | { "name": string, "url?": string } — Author info. |
owners |
string[] | No | GitHub logins (lowercase, ^[a-z0-9][a-z0-9-]{0,38}$). Gates who can submit registry PRs bumping this app's pinned commit, and who can manage env vars via the Developer Dashboard. |
icon |
string | No | Relative path to icon file. Defaults to icon.png. |
categories |
string[] | No | Category IDs (see Categories). Only the first entry is used; extras are ignored. |
tags |
string[] | No | Searchable tags for discovery. |
ui |
object | No | UI configuration. Omit for tools-only apps. |
ui.entry |
string | No | Entry point relative to repo root. Default: ui/index.html. |
ui.width |
integer | No | Default window width. Default: 800. |
ui.height |
integer | No | Default window height. Default: 600. |
auth |
object | No | Authentication configuration — see Authentication. |
auth.schemes |
array | No | Array of supported auth schemes. The user picks one when connecting. |
permissions |
object | No | Declared permissions shown to users during install. |
permissions.network |
string[] | No | External domains this app connects to. |
permissions.storage |
string | No | Maximum storage needed (e.g., "1MB"). |
permissions.uses.tools |
string[] | No | Managed platform tools this app may call through ctx.construct.tools.call(). See Calling Platform Tools. |
permissions.uses.apps |
array | No | Other Construct apps this app may call through ctx.construct.apps.call(). Each item is { app_id, tools: [...] }. |
tools |
array | No | Pre-declared tool list. Auto-discovered on deploy if omitted. |
Your server.ts file is a Cloudflare Worker that handles MCP JSON-RPC requests. It must respond to three methods:
| Method | Description |
|---|---|
initialize |
Returns protocol info and capabilities |
tools/list |
Returns a list of available tools |
tools/call |
Executes a tool and returns the result |
You can write the MCP handler from scratch, but it's easier to use the ConstructApp SDK:
import { ConstructApp } from '@construct-computer/app-sdk';
const app = new ConstructApp({ name: 'my-app', version: '1.0.0' });
app.tool('hello', {
description: 'Say hello to someone',
parameters: {
name: { type: 'string', description: 'Who to greet' },
},
handler: async (args) => {
return `Hello, ${args.name}!`;
},
});
export default app;That's it. The SDK handles JSON-RPC routing, CORS, and the initialize/tools/list methods automatically.
Each tool has a name, description, parameters, and a handler:
app.tool('search_products', {
description: 'Search for products on the marketplace',
parameters: {
query: { type: 'string', description: 'Search terms' },
category: { type: 'string', enum: ['electronics', 'clothing', 'books'], description: 'Product category' },
limit: { type: 'number', description: 'Max results (default: 10)', default: 10 },
},
handler: async (args) => {
const query = args.query as string;
const category = args.category as string;
const limit = (args.limit as number) || 10;
// ... your logic here
return `Found ${limit} results for "${query}" in ${category}`;
},
});Parameter types: string, number, boolean, array, object. Use enum for fixed choices and description to help the AI decide when to use each tool.
A handler can return:
-
A string — automatically wrapped in a text content block:
handler: async (args) => 'Hello, World!'
-
A ToolResult object — for multiple content blocks or error states:
handler: async (args): Promise<ToolResult> => { if (!args.query) { return { content: [{ type: 'text', text: 'Query is required' }], isError: true }; } return { content: [{ type: 'text', text: 'Results found' }] }; }
When your app is bundled into the registry worker, imports like @construct-computer/app-sdk won't resolve. You have two options:
-
Inline the SDK class in your
server.ts. The SDK is ~150 lines and self-contained — copy theConstructAppclass directly into your server file. -
Use ES module bundling — if you prefer imports, add a build step:
{ "scripts": { "build": "esbuild server.ts --bundle --format=esm --outfile=dist/worker.js --platform=browser", "dev": "wrangler dev" } }Then set
main = "dist/worker.js"inwrangler.toml.
Install the SDK for local development with types:
pnpm add @construct-computer/app-sdkCreates a new app instance.
import { ConstructApp } from '@construct-computer/app-sdk';
const app = new ConstructApp({ name: 'my-app', version: '1.0.0' });Register a tool. Returns this for chaining.
app
.tool('tool_a', { description: '...', handler: async () => 'OK' })
.tool('tool_b', { description: '...', handler: async () => 'OK' });Cloudflare Worker entry point. Automatically handles MCP routing, CORS, /ui/* path rewriting, and static asset serving via the Cloudflare ASSETS binding (when present). Export as default:
export default app;Throws if the user isn't authenticated. Use in handlers that need OAuth:
import { requireAuth } from '@construct-computer/app-sdk';
app.tool('get_my_account', {
description: 'Get authenticated user account',
handler: async (args, ctx) => {
requireAuth(ctx);
// ctx.auth.access_token is now guaranteed to exist
const response = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${ctx.auth.access_token}` },
});
return await response.text();
},
});Every handler receives a ctx (RequestContext) with:
| Field | Type | Description |
|---|---|---|
ctx.userId |
string | undefined |
User ID from the x-construct-user header |
ctx.auth |
object | undefined |
Credentials from the x-construct-auth header |
ctx.auth.type |
string |
Auth scheme: 'oauth2' | 'api_key' | 'bearer' | 'basic' |
ctx.auth.access_token |
string | undefined |
OAuth2 access token |
ctx.auth.refresh_token |
string | undefined |
OAuth2 refresh token |
ctx.auth.expires_at |
number | undefined |
OAuth2 token expiry (epoch ms) |
ctx.auth[fieldName] |
unknown |
Dynamic fields from api_key/bearer/basic scheme fields[] |
ctx.isAuthenticated |
boolean |
Whether valid credentials are present |
ctx.request |
Request |
The raw HTTP request |
ctx.env |
Record<string, string> |
App environment variables from the developer dashboard |
ctx.construct |
ConstructBridge |
Bridge into platform capabilities — see Calling Platform Tools. |
Your app can reach into the Construct platform from inside a tool handler using ctx.construct:
app.tool('list_files', {
description: 'List files in the user\'s cloud drive.',
parameters: { limit: { type: 'number' } },
handler: async (args, ctx) => {
const result = await ctx.construct.tools.call('drive.list_files', {
limit: args.limit ?? 10,
});
return result.text;
},
});Every capability your app uses must be declared in manifest.json under permissions.uses:
"permissions": {
"uses": {
"tools": ["drive.list_files", "calendar.list_events"],
"apps": [
{ "app_id": "sample-summarizer", "tools": ["summarize"] }
]
}
}Undeclared calls are rejected by the gateway with 403 forbidden.
| Method | Purpose |
|---|---|
ctx.construct.tools.call(name, args?) |
Invoke a managed platform tool. name must be an exact entry from the public catalog (see Discovering Available Platform Tools) and must be listed in permissions.uses.tools. |
ctx.construct.apps.call(appId, toolName, args?) |
Invoke a tool on another Construct app. The (appId, toolName) pair must be whitelisted in permissions.uses.apps. |
Both methods return { data: unknown, text: string }. On failure, they throw a ConstructCallError with a stable code you can branch on:
import { ConstructCallError } from '@construct-computer/app-sdk';
try {
const r = await ctx.construct.tools.call('drive.list_files', { limit: 5 });
return r.text;
} catch (err) {
if (err instanceof ConstructCallError && err.code === 'not_connected') {
return 'Please connect your drive in Settings first.';
}
throw err;
}The full list of managed platform tools — the only names you may put in permissions.uses.tools — is served live from the Construct worker:
curl https://beta.construct.computer/v1/toolsThe response is:
{
"count": 33,
"tools": [
{
"name": "drive.list_files",
"description": "List files in the user's cloud drive. Returns name, id, mime type, and modified time.",
"inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } }
},
{ "name": "drive.get_file", "description": "…", "inputSchema": { "…": "…" } }
]
}Use it to:
- Browse namespaces before deciding what your app needs (
drive.*,calendar.*,mail.*,notes.*,sheets.*,docs.*,code.*,chat.*,payments.*,notify.*). - Copy the exact
namestrings intomanifest.permissions.uses.tools. - Inspect each tool's
inputSchemaso you know what argumentsctx.construct.tools.call()expects.
Rules:
- Exact names only. Wildcards like
drive.*are not supported —permissions.uses.toolsmust list each tool name verbatim, and the runtime check is a literal string match. Any name missing from/v1/toolsis rejected at dispatch time withunknown_tool. - The catalog is versioned as a stable public contract. Backing providers may change, but the public
<namespace>.<verb>names do not. - During staging you can hit
https://staging.construct.computer/v1/toolsinstead.
ConstructCallError.code is one of:
| Code | Source | Meaning |
|---|---|---|
no_bridge |
SDK | Request did not come through the Construct platform — no x-construct-call-token header was present. Always thrown by the local stub (e.g. direct curl, wrangler dev). Never returned by the gateway. |
bad_request |
SDK | You passed a non-string name/appId/toolName to tools.call / apps.call. |
network_error |
SDK | fetch() to the gateway threw (transient connectivity). |
bad_response |
SDK | Gateway returned a non-JSON response. |
missing_token / invalid_token |
Gateway (401) | Token was missing or failed verification. Contact platform support if you see this from a deployed app. |
gateway_disabled |
Gateway (503) | The platform gateway is intentionally off (dev or incident). |
forbidden |
Gateway (403) | The call wasn't declared in manifest.permissions.uses. Add the tool or (app_id, tool) pair to your manifest and bump the pinned commit. |
unknown_tool |
Gateway (404) | No such tool in the public catalog. Check /v1/tools for the current list. |
permissions_unavailable |
Gateway (503) | Registry lookup for your app's declared permissions failed transiently — safe to retry. |
not_connected |
Gateway (424) | The user hasn't linked the backing service (e.g. hasn't signed into Drive). Prompt them to connect in Construct Settings. |
missing_link |
Gateway (424) | Similar to not_connected — the required external account link is absent. |
rate_limited |
Gateway (429) | Exceeded ~30 combined gateway calls / 60s for this (user, app). Soft per-isolate sliding window; response includes retry_after_sec. |
depth_limit |
Gateway (400) | Cross-app chain too deep. MAX_CALL_DEPTH = 3, which means a token with depth ≥ 2 cannot make further apps.call invocations (so the deepest valid chain is Agent → App A → App B → App C; C cannot call D). |
self_call |
Gateway (400) | An app tried to apps.call itself. |
target_unknown |
Gateway (404) | Target app_id isn't registered. |
target_unavailable |
Gateway (424) | Target app is registered but hasn't been deployed yet (base_url missing). |
target_unreachable |
Gateway (502) | Network error reaching the target app's worker. |
target_error / bad_target_response |
Gateway (502) | Target app returned a non-2xx, non-JSON, or invalid JSON-RPC reply. |
tool_error |
Gateway (502) | A target tool returned isError: true. |
backend_error / backend_unavailable / unknown_backend |
Gateway (502/503) | The managed-tool backend (Composio etc.) failed or is offline. |
In local dev (no x-construct-call-token header on the request) ctx.construct is a stub that throws ConstructCallError('no_bridge', ...) for every method. Deploy the app and reach it via the platform to get real dispatch. You can still browse the tool catalog locally — curl https://beta.construct.computer/v1/tools works from anywhere.
See the apps/sample-app for a working end-to-end example — look for the send_notification and list_upcoming_events tools in server.ts.
If your app has a visual interface, create a ui/index.html file and add the ui field to your manifest:
{
"ui": {
"entry": "ui/index.html",
"width": 800,
"height": 600
}
}- Your
ui/index.html(and any CSS/JS/images underui/) is proxied by the registry worker fromraw.githubusercontent.com/{owner}/{repo}/{commit}/ui/...at the pinned commit, and served from your app's subdomain at/ui/*. - Construct loads it in a sandboxed iframe inside a desktop window.
- Load the Construct SDK from the registry's canonical URL:
https://registry.construct.computer/sdk/construct.js— core bridge (tools,ui,ready).https://registry.construct.computer/sdk/construct.css— design-system CSS variables + utility classes. These are served with CORS open andCache-Control: public, max-age=3600. Use the same URL in dev and prod — no dev-server inlining, no relative/sdk/paths to worry about.
- Your UI communicates with the platform and your MCP server via
window.postMessagethrough the SDK bridge.
Construct-desktop injection: When the iframe is mounted inside Construct, the desktop fetches your HTML, strips any
<script src=".../construct.js">/<link href=".../construct.css">tags, and injects its own inline bridge that additionally exposesconstruct.stateandconstruct.agent(desktop-only APIs). So outside of Construct (e.g. openinghttp://localhost:8787/directly in a browser) the registry-hosted SDK loads as-is and you get the core API only; inside Construct the richer bridge replaces it automatically.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<link rel="stylesheet" href="https://registry.construct.computer/sdk/construct.css">
<script src="https://registry.construct.computer/sdk/construct.js"></script>
</head>
<body>
<div class="app">
<input type="text" id="input" placeholder="Enter text..." />
<button class="btn" onclick="runTool()">Go</button>
<div id="output"></div>
</div>
<script>
construct.ready(() => {
construct.ui.setTitle('My App');
});
async function runTool() {
const result = await construct.tools.callText('hello', {
name: document.getElementById('input').value
});
document.getElementById('output').textContent = result;
}
</script>
</body>
</html>For local development, add an [assets] section to wrangler.toml:
name = "construct-app-myapp"
main = "server.ts"
compatibility_date = "2024-12-01"
[assets]
directory = "./ui"
binding = "ASSETS"
not_found_handling = "none"
run_worker_first = ["/*"]The run_worker_first = ["/*"] setting ensures all requests hit your server first. The SDK's fetch() handler automatically:
- Routes
/mcpand/healthto the MCP server - Rewrites
/ui/*requests to/*(so dev matches the published URL structure) - Serves static files from
ui/via theASSETSbinding - Adds CORS headers to every response
No manual asset-serving code needed — just export default app.
Note: In production the registry proxies UI files from your GitHub repo at the pinned commit. The
ASSETSbinding above is only for localwrangler dev. You do not need to serve/sdk/*from your own worker — load the SDK fromhttps://registry.construct.computer/sdk/construct.{js,css}in both dev and prod.
The SDK is a postMessage bridge that lets your app's UI communicate with the Construct platform. It works through two layers:
https://registry.construct.computer/sdk/construct.js— The core bridge loaded in yourui/index.html. Providestools,ui, andready.- Construct desktop bridge — When your app runs inside Construct, the parent frame provides additional methods (
state,agent) via the same bridge.
Note: When testing your UI locally outside of Construct, only the core methods (
tools,ui,ready) are available. Thestateandagentnamespaces require the Construct desktop environment.
Add these two lines to your ui/index.html:
<link rel="stylesheet" href="https://registry.construct.computer/sdk/construct.css">
<script src="https://registry.construct.computer/sdk/construct.js"></script>These methods are available both in standalone testing and when running inside Construct.
Wait for the SDK bridge to be ready. Always wrap your initialization code in this.
construct.ready(() => {
construct.ui.setTitle('My App');
});Call one of your app's MCP tools. Returns the full result object.
const result = await construct.tools.call('search_products', { query: 'laptop' });
// result = { content: [{ type: 'text', text: '...' }], isError?: boolean }Call a tool and get just the text content. Most common for simple results.
const text = await construct.tools.callText('hello', { name: 'World' });
// text = "Hello, World!"Update the window title bar text.
construct.ui.setTitle('Search Results');Get the current Construct theme (dark/light mode and accent color).
const theme = await construct.ui.getTheme();
// theme = { mode: 'dark', accent: '#60A5FA' }Close this app window.
These methods are only available when your app is running inside the Construct desktop. They communicate through the parent frame's bridge.
Read your app's persistent state (stored server-side, max 1MB). Primarily designed for local apps created by the AI agent.
const state = await construct.state.get();
console.log(state.lastSearch);Write state. Triggers onUpdate callbacks on all connected clients.
await construct.state.set({ lastSearch: 'laptop', recentItems: [] });Subscribe to state changes (from the agent or other tabs).
construct.state.onUpdate((newState) => {
console.log('State updated:', newState);
renderFromState(newState);
});Send a message to the AI agent. The agent can then respond by calling your tools or updating your app state.
await construct.agent.notify('User clicked the search button');The construct.css SDK provides a dark theme with CSS variables:
:root {
--c-bg: #0a0a12;
--c-surface: rgba(255,255,255,0.04);
--c-surface-hover: rgba(255,255,255,0.06);
--c-surface-raised: rgba(255,255,255,0.08);
--c-text: #e4e4ed;
--c-text-secondary: rgba(228,228,237,0.7);
--c-text-muted: rgba(228,228,237,0.4);
--c-accent: #6366f1;
--c-accent-muted: rgba(99,102,241,0.15);
--c-border: rgba(255,255,255,0.08);
--c-error: #ef4444;
--c-radius-sm: 6px;
--c-radius-md: 10px;
--c-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--c-font-mono: "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
}Utility classes: .btn, .btn-secondary, .btn-sm, .badge, .badge-accent, .fade-in, .container.
Type definitions for the full SDK (including state and agent namespaces) are available at ui/construct.d.ts. Copy src/construct-global.d.ts from the app-sdk into your ui/ directory (the template repo includes it already). Add it to your project for autocomplete:
/// <reference path="./construct.d.ts" />Construct supports four auth schemes for apps. You can declare any combination — users pick the scheme they prefer when connecting.
| Type | Use when |
|---|---|
oauth2 |
The provider supports standard OAuth 2.0 (authorization code flow). |
api_key |
Users have a long-lived API key. |
bearer |
Users paste a bearer/access token directly. |
basic |
HTTP Basic auth (username + password). |
Each scheme entry has a type, a label shown in the UI, and type-specific fields.
{
"auth": {
"schemes": [
{
"type": "oauth2",
"label": "Sign in with Example",
"authorization_url": "https://api.example.com/oauth/authorize",
"token_url": "https://api.example.com/oauth/token",
"scopes": ["read", "write"],
"scope_separator": " "
},
{
"type": "api_key",
"label": "Use API Key",
"instructions": "Get your key at https://api.example.com/settings/keys",
"fields": [
{ "name": "api_key", "displayName": "API Key", "type": "password", "required": true, "placeholder": "sk-..." }
]
},
{
"type": "bearer",
"label": "Use Bearer Token",
"fields": [
{ "name": "token", "displayName": "Access Token", "type": "password", "required": true }
]
},
{
"type": "basic",
"label": "Use Username and Password",
"fields": [
{ "name": "username", "displayName": "Username", "type": "text", "required": true },
{ "name": "password", "displayName": "Password", "type": "password", "required": true }
]
}
]
}
}Field reference for a credential scheme (api_key / bearer / basic):
| Field | Required | Description |
|---|---|---|
name |
Yes | Key under which the value is delivered to your server via ctx.auth[name]. |
displayName |
Yes | Label shown to users. |
type |
Yes | text or password. |
required |
Yes | Whether the user must fill this in. |
placeholder |
No | Hint text. |
description |
No | Help text below the field. |
Legacy format still supported:
{ "auth": { "oauth2": { ... } } }is normalized into a single-schemeschemes[]automatically. New apps should useschemes[].
OAuth requires a client_id + client_secret issued by the provider. These are not stored in your public manifest. Instead, the Construct platform holds them as Cloudflare Worker secrets, keyed by app id:
APP_OAUTH_<APP_ID_UPPER_UNDERSCORE>_CLIENT_ID
APP_OAUTH_<APP_ID_UPPER_UNDERSCORE>_CLIENT_SECRET
For an app with id mercadolibre these become APP_OAUTH_MERCADOLIBRE_CLIENT_ID and APP_OAUTH_MERCADOLIBRE_CLIENT_SECRET.
To enable OAuth for your app:
- Register a developer app with the provider (e.g. Google Cloud Console, MercadoLibre DevCenter).
- Set the OAuth redirect/callback URL to:
- Staging:
https://staging.construct.computer/api/apps/connect/callback - Production:
https://beta.construct.computer/api/apps/connect/callback
- Staging:
- Open an issue on construct-computer/app-registry requesting OAuth credentials be added for your app id. A maintainer will add the secrets via
wrangler secret put.
Until those secrets are set, OAuth connect attempts will return oauth_not_configured — users can still connect with any credential-based scheme you've declared.
import { ConstructApp, requireAuth, RequestContext } from '@construct-computer/app-sdk';
app.tool('get_my_account', {
description: 'Get the authenticated user account',
handler: async (args, ctx) => {
requireAuth(ctx); // throws if not authenticated
// ctx.auth contains the fields from whichever scheme the user chose.
// For OAuth: ctx.auth.access_token, ctx.auth.refresh_token, ctx.auth.expires_at
// For api_key scheme with a field named "api_key": ctx.auth.api_key
// For bearer scheme with a field named "token": ctx.auth.token
// For basic: ctx.auth.username, ctx.auth.password
// ctx.auth.type tells you which scheme was used: 'oauth2' | 'api_key' | 'bearer' | 'basic'
const token = ctx.auth.access_token || ctx.auth.api_key || ctx.auth.token;
const response = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json();
return JSON.stringify(data, null, 2);
},
});Tip: Name your api_key / bearer field access_token to let a single ctx.auth.access_token handler work for both OAuth and token-paste flows.
- The user picks a scheme and connects their account.
- For OAuth2 → Construct redirects to the provider, exchanges the code for tokens, and stores them encrypted (AES-256-GCM).
- For credential schemes → Construct stores the submitted fields encrypted.
- On every tool call, Construct auto-refreshes expired OAuth tokens, then injects the
x-construct-authheader:x-construct-auth: {"type":"oauth2","access_token":"...","refresh_token":"...","expires_at":1712345678000} - The SDK parses this into
ctx.authand setsctx.isAuthenticated = true.
You can mix public and authenticated tools in the same app:
// Public — works for everyone
app.tool('search_products', {
description: 'Search products (no login required)',
handler: async (args) => { /* ... */ },
});
// Authenticated — requires connected account
app.tool('manage_listing', {
description: 'Update a product listing (requires seller account)',
handler: async (args, ctx) => {
requireAuth(ctx);
// ctx.auth.access_token available here
},
});pnpm devThis runs wrangler dev and starts your app at http://localhost:8787.
# Health check
curl http://localhost:8787/health
# → ok
# Initialize (handshake)
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"initialize","id":1}'
# → {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
# List tools
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}'
# → {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
# Call a tool
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"hello","arguments":{"name":"World"}},"id":3}'
# → {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Hello, World!"}]}}- Start your dev server:
pnpm dev - Open Construct → Settings → Developer
- Toggle Developer Mode on
- Under Connect Dev Server, paste
http://localhost:8787and click Connect
Construct validates your server by calling GET /health and POST /mcp (initialize + tools/list), then registers your app with the agent. Click Open App to launch your UI in a desktop window.
What Construct probes on your dev server:
| Request | Purpose |
|---|---|
GET /health |
Liveness check; must return HTTP 200 |
POST /mcp (initialize) |
Read server name/version |
POST /mcp (tools/list) |
Register tools with the agent |
HEAD /, HEAD /ui/index.html, HEAD /index.html |
UI entry-point detection (content-type must include html) |
GET /icon.png, GET /icon.svg, GET /favicon.ico |
Icon for the Dev window header |
When the window opens, Construct fetches your HTML and strips any <script src="…construct.js"> / <link href="…construct.css"> tags before injecting its own inline bridge (which additionally exposes construct.state and construct.agent). Your template references the registry-hosted SDK so the same HTML works in both standalone browser testing (outside Construct) and inside the desktop.
Tip: For remote testing, use cloudflared to create a tunnel:
cloudflared tunnel --url http://localhost:8787, then paste the tunnel URL in Connect Dev Server.
Simulate authenticated requests by adding the x-construct-auth header:
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-H 'x-construct-auth: {"access_token":"test-token","user_id":"user-123"}' \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_my_account","arguments":{}},"id":1}'Registry apps run bundled inside a shared Cloudflare Worker, so you can't set secrets on your own Worker script the way you would for a standalone app. Instead, Construct provides a developer dashboard where app owners manage per-app env vars that the platform injects at dispatch time.
- API keys, shared secrets, webhook tokens — anything you'd normally put in
wrangler.tomlorwrangler secret put. - Values are scoped to your app only. The registry decrypts them just before calling your handler and passes them as a request header. Other apps running in the same bundled worker never receive them.
- Updates take effect on the next request after you save — no re-deploy.
Add your GitHub login (and any co-maintainers) to manifest.json:
{
"name": "My App",
"owners": ["your-github-login", "coworker-login"],
...
}After the PR merges, sign in at https://registry.construct.computer/dev
with GitHub. You'll see each app whose owners[] contains your login.
- Go to
https://registry.construct.computer/dev/apps/<your-app-id> - Enter a
NAME(must match^[A-Z][A-Z0-9_]{0,63}$) andvalue - Save — the value is AES-256-GCM encrypted at rest. Names with the
CF_,CLOUDFLARE_, orCONSTRUCT_prefix are reserved.
Only logged-in users listed in the app's owners[] can see or modify these;
values are never shown back, only their names and last-updated timestamps.
The registry sets the x-construct-env header on the request that reaches
your handler. The header value is base64-encoded JSON containing only
your app's variables (stripped + replaced on every dispatch, so nothing
from outside can leak in).
Using the SDK ctx.request:
function readEnv(request: Request): Record<string, string> {
const raw = request.headers.get('x-construct-env');
if (!raw) return {};
try {
return JSON.parse(atob(raw));
} catch {
return {};
}
}
app.tool('webhook_ping', {
description: 'Send a ping to the webhook configured in env.',
handler: async (_args, ctx) => {
const env = readEnv(ctx.request);
const url = env.WEBHOOK_URL;
if (!url) return '(WEBHOOK_URL is not set; open the developer dashboard to configure it)';
await fetch(url, { method: 'POST' });
return 'pinged';
},
});Because registry apps share a single Worker isolate, isolation is defense-in-depth rather than kernel-level:
- Bound: the registry looks up env vars from D1 using the router-
determined app id (your app's subdomain), not anything the app code can
set. Another app cannot ask for your env, and your env is never placed in
the global Worker
envbinding. - Bound: any inbound
x-construct-env*header is stripped before dispatch, so callers can't pre-seed one. - Unbound: apps sharing an isolate can still, in principle, monkey-
patch globals like
fetchorHeaders.prototype.getat module load time. Don't publish apps that do this, and don't treat co-tenant apps as fully untrusted. For highly sensitive workloads, run your own Worker outside the registry and connect via an external base URL.
Make sure your app has all required files:
-
manifest.json— with at leastnameanddescription -
server.ts(orsrc/index.tsorindex.ts) — MCP server entry point -
icon.png— 256×256 icon (oricon.svg,icon.jpg) -
README.md— used as the store description
Create a public repository for your app. The recommended naming convention is construct-app-{name}:
git init && git add -A
git commit -m "Initial release"
git remote add origin git@github.com:you/construct-app-myapp.git
git push -u origin mainFind the full 40-character SHA of the commit you want to publish:
git rev-parse HEAD
# → abc123def456789abc123def456789abc123def4This pins your app to an exact, auditable version.
Fork construct-computer/app-registry and add a file at apps/{your-app-id}.json:
{
"repo": "https://github.com/you/construct-app-myapp",
"versions": [
{
"version": "1.0.0",
"commit": "abc123def456789abc123def456789abc123def4",
"date": "2026-04-10"
}
]
}The pointer only needs repo and versions. The store listing (name, description, icon, etc.) is read from your repo's manifest.json at the pinned commit.
App ID rules (filename without .json):
- Must match
^[a-z0-9][a-z0-9-]{0,40}[a-z0-9]?$(kebab-case, DNS-safe, ≤42 chars). - Reserved names cannot be used:
registry,apps,api,www,mail,mx,auth,cdn,static,assets,docs,blog,app,beta,staging,production,dev,preview,admin,dashboard,status,support. - The registry allocates a permanent random suffix, so your app lives at
https://{your-app-id}-{nanoid}.apps.construct.computer.
CI (.github/workflows/validate-pr.yml) will automatically validate your app:
- Clones your repo at the pinned commit
- Validates
manifest.jsonhas required fields (name,description) - Ownership check: if
manifest.owners[]is set, the PR author's GitHub login must be in it (case-insensitive). On the first publish (noowners[]yet) the PR passes but CI warns that future bumps will require an owner. Add yourself and co-maintainers toowners[]on day one to lock down your app id. - Checks that an entry point exists (
server.ts,src/index.ts, orindex.ts) - Verifies
icon.png(or.svg/.jpg) exists - Verifies
README.mdexists - Type-checks your server (via
pnpm buildifpackage.jsonexists, otherwisedeno check)
Once a maintainer reviews and merges your PR:
- Sync — Your app metadata is pushed to the D1 database
- Bundle — Your server code is bundled into the registry worker
- Deploy — The worker is deployed to Cloudflare
- Extract tools — Your app's tools are discovered and cached
Your app appears in the Construct App Registry within minutes!
To publish a new version:
- Push the update to your app repo
- Get the new commit SHA:
git rev-parse HEAD - Open a PR to the registry adding a new version entry:
{
"repo": "https://github.com/you/construct-app-myapp",
"versions": [
{ "version": "1.0.0", "commit": "abc123...", "date": "2026-04-01" },
{ "version": "1.1.0", "commit": "def456...", "date": "2026-04-10" }
]
}The last entry in the versions array becomes the "latest" version shown in the store. Previous versions are still available in the version history.
Bumps require ownership. If your app's
manifest.owners[]lists anyone, only those GitHub logins can open version-bump PRs. If you need a coworker to publish, add their login toowners[]in your app repo first, then they can open the bump PR.
Understanding the pipeline helps you debug issues:
Your repo app-registry repo Cloudflare
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ manifest.json │ │ apps/my-app.json │──CI──▶ │ D1 database │
│ server.ts │◀─pointer─│ (repo + commits) │ │ (search, browse) │
│ icon.png │ │ │──CI──▶ │ │
│ ui/index.html │ │ │ │ Worker bundles │
│ README.md │ │ │ │ your server.ts │
└─────────────────┘ └──────────────────┘ │ into the runtime │
└───────────────────┘
- You create a PR adding
apps/{id}.jsonto the registry repo - CI validates your app (manifest, entry point, icon, README)
- On merge, CI runs three scripts:
scripts/sync.ts— Clones your repo, reads manifest, pushes metadata to D1scripts/bundle-apps.sh— Copies yourserver.tsinto the worker, patches it, generates a handler registryscripts/extract-tools.sh— Calls your app'stools/listendpoint to cache tool definitions
- The worker is deployed with your app handler bundled in
Your app code is bundled directly into the registry worker. There's no separate deployment per app — the worker routes /{appId}/mcp to your handler.
Assets (icons, screenshots, UI files) are served from raw.githubusercontent.com/{owner}/{repo}/{commit}/... — no separate asset storage needed.
Use these category IDs in your manifest's categories array:
| ID | Label |
|---|---|
productivity |
Productivity |
developer-tools |
Developer Tools |
communication |
Communication |
finance |
Finance |
media |
Media |
ai-tools |
AI Tools |
data |
Data & Analytics |
utilities |
Utilities |
integrations |
Integrations |
shopping |
Shopping |
games |
Games |
Public, cached, no auth:
| Method | Path | Description |
|---|---|---|
GET |
/v1/apps |
List/search apps. Params: q, category, sort (popular/recent/rating/name), page, limit |
GET |
/v1/apps/:id |
App detail — metadata, versions, reviews |
GET |
/v1/apps/:id/download |
302 redirect to repo tarball (latest version) and increments install count |
GET |
/v1/apps/:id/download/:version |
302 redirect to repo tarball for a specific version |
GET |
/v1/categories |
Categories with app counts |
GET |
/v1/featured |
Featured apps and collections |
GET |
/v1/curated |
Curated third-party integrations |
GET |
/health |
Liveness check |
Internal / authenticated (used by the registry's own CI and the Construct backend):
| Method | Path | Description |
|---|---|---|
POST |
/v1/sync |
Upsert app data from apps/*.json. Requires Authorization: Bearer $SYNC_SECRET. |
POST |
/v1/apps/:id/installed |
Increment install count (fire-and-forget). |
POST |
/v1/apps/:id/tools |
Update cached tool definitions after deploy. Requires Authorization: Bearer $SYNC_SECRET. |
Every published app is routed from its own DNS label under apps.construct.computer:
https://{your-app-id}-{nanoid}.apps.construct.computer
The {nanoid} suffix is allocated on first publish and stays stable across version bumps. Find the exact URL for your app in /v1/apps/:id → base_url.
All routes below are served from that subdomain (not from apps.construct.computer directly and not with any {appId} prefix):
| Method | Path | Description |
|---|---|---|
POST |
/mcp |
MCP JSON-RPC endpoint. Dispatches to your bundled handler. |
GET |
/health |
Liveness check — returns ok. |
GET |
/ui, /ui/* |
UI files — proxied from raw.githubusercontent.com/{owner}/{repo}/{commit}/... at the pinned commit. /ui and /ui/ map to /ui/index.html. |
GET |
/icon, /icon.png |
App icon — proxied from the repo at the pinned commit. |
GET |
/sdk/construct.css |
Construct SDK CSS, same-origin mirror. Canonical URL is https://registry.construct.computer/sdk/construct.css — prefer that. |
GET |
/sdk/construct.js |
Construct SDK bridge, same-origin mirror. Canonical URL is https://registry.construct.computer/sdk/construct.js — prefer that. |
Headers injected by the registry on every POST /mcp dispatch (you cannot set these from outside — they are stripped and rewritten per-call):
x-construct-user— authenticated user id (when available).x-construct-auth— JSON-encoded credentials when the user has connected this app (see Authentication).x-construct-env— base64-encoded JSON of your app's developer-dashboard env vars (see Environment Variables).
Common validation errors:
| Error | Fix |
|---|---|
Missing manifest.json |
Add a manifest.json to your repo root |
| Missing required fields | Ensure name and description are in your manifest |
| No entry point found | Create server.ts, src/index.ts, or index.ts |
| No icon file found | Add icon.png (256×256), icon.svg, or icon.jpg |
Missing README.md |
Add a README.md to your repo root |
pnpm build failed |
Check that your server.ts compiles without errors |
PR author not in owners[] |
The registry PR author's GitHub login must be listed in manifest.owners[] once the array is non-empty. Add them in a PR to the app repo, bump the pinned commit, then re-open the registry PR. |
- Make sure the PR was merged (not just opened)
- Check that the commit SHA in your pointer file matches an actual commit
- Wait a few minutes after merge — the sync pipeline needs to run
This means your app handler isn't bundled in the worker yet. Check that:
- The
bundle-apps.shscript found your app - The
server.tsexportsdefault app(or uses the SDK pattern)
- Make sure
manifest.jsonhas theuifield - Check that
ui/index.htmlexists in your repo - Verify your HTML loads the SDK from
https://registry.construct.computer/sdk/construct.jsand.../construct.css - Test locally with
wrangler devand the[assets]config
The x-construct-auth header is only present when the user has connected their account through Construct. In local development, you can add headers manually:
curl -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-H 'x-construct-auth: {"access_token":"test-token","user_id":"test-user"}' \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"my_tool","arguments":{}},"id":1}'- App Store — Browse apps
- Publishing Guide — Step-by-step guide
- App SDK — Build apps with TypeScript
- Sample App (Text Tools) — Template repo with nine example tools and UI
- Manifest Schema — JSON Schema for IDE validation