Guide

HTML surface

A workspace can hold tabs of three kinds: table, doc, and html. An html surface is a sandboxed iframe rendering whatever HTML, CSS, and JS the workspace stores against it. Use it for mockups, prototypes, landing-page drafts, anything page-shaped.

When to use this

For mockups that fit inside a brief, prefer the ```html doc-block format — it lives next to your prose and uses the same renderer. See doc formats for the block.

Reach for an html surface when the mockup is the artifact, not an illustration inside another artifact. Examples: a landing-page draft an agent iterates on across sessions, a one-page summary template, a prototype that's getting feedback from the team in workspace comments.

Workspace UI

An html surface renders edge-to-edge inside the workspace area — there’s no chrome at the top of the iframe. View switching + save state live in a small floating Dock that floats over the preview:

  • Preview · HTML · CSS pills switch between the rendered iframe and the CodeMirror editors for each field. Active pill carries the brand-hue gradient.
  • Expand (corner-arrows icon) pops the preview to a full-viewport portal. Works from any view — clicks the pill row over to Preview first, then opens. ESC or the close button returns.
  • Orientation toggle swaps between bottom-center horizontal (default) and top-right vertical placements. Picked icon points to where the dock would go on click.
  • Clip (chevron) collapses the dock body to just itself; click again to expand. Position + collapsed state persist in localStorage so the dock stays where you put it across reloads.
  • The dock’s glass background adapts to the iframe’s body color— translucent blur on dark mockups, more opaque “frosted black” on light mockups, so the control surface stays readable against any rendered content.

Commenting on elements

Click any element in the rendered preview to drop a comment at that point. A small composer pops at the click coordinates with the anchored phrase as an italic header; type and hit Post (or ⌘↩) to anchor the thread. ESC or click-outside dismisses without posting.

Existing threads render as small pink dots at the top-right of each anchored element. Click a dot to open the full sidebar scoped to that thread — replies, reactions, resolve, all the same affordances as a doc comment. The sidebar is the rich thread view; the popover is the quick-create path.

From an agent, the same threads are POSTable via the universal comments endpoint with target.type = "html_element":

MCP add_comment (or POST /api/workspaces/:slug/comments)json
add_comment({
  slug: "my-workspace",
  target: {
    type: "html_element",
    surfaceSlug: "landing-v2",
    anchor: {
      selector: "section.hero > h1",
      text: "Workspaces built for both of you."
    }
  },
  body: "Love this headline."
})

selector is the CSS path the resolver tries first; the optional 60-char text snippet is a fuzzy fallback for when the DOM is regenerated and the selector no longer resolves. Click-capture inside the iframe emits both on every click.

Creating one

In the dashboard, click the + on the tab bar and pick HTML. From an agent, call create_surface with kind: "html":

MCP create_surfacejson
create_surface({
  slug: "my-workspace",
  kind: "html",
  name: "Landing v2"
})

Writing content

The body has three fields stored separately: html, css, and js. Update any of them through update_html; omit a field to leave it unchanged, pass an empty string to clear it.

MCP update_htmljson
update_html({
  slug: "my-workspace",
  surface_slug: "landing-v2",
  html: "<section class=\"hero\"><h1>Welcome</h1></section>",
  css: ".hero { padding: 64px; background: #f6f6f6; }"
})

Read the current body with get_html:

MCP get_htmljson
get_html({
  slug: "my-workspace",
  surface_slug: "landing-v2"
})
// → { html, css, js, updatedAt, updatedBy, surface_slug }

Pre-flight what the sanitizer will do with validate_html:

MCP validate_htmljson
validate_html({
  html: "<div onclick=\"x\">test</div>"
})
// → {
//   ok: true,
//   warnings: [{ code: "html_sanitized", message: "..." }],
//   parsed: { htmlBytes, cssBytes, jsBytes, totalBytes, htmlDropped: true }
// }

Caps

FieldPer-field
html50 KB (post-sanitize)
css100 KB
js100 KB (stored, not executed at v1)
total envelope250 KB

Security model

The body is sanitized at write time and rendered in a sandboxed iframe. The sanitizer strips:

  • <script>, <iframe>, <object>, <embed>, <foreignobject>
  • Every on* event handler (onclick, onload, etc.)
  • javascript:, vbscript:, file:, and data:text/html URIs
  • Header-injection tags: <meta>, <base>, <link>
  • Form action smuggling (formaction, formmethod, etc.)

What's allowed:

  • The standard structural HTML5 tag set
  • <style> tags with CSS
  • Inline ARIA attributes
  • Form markup (rendered but inert because scripts are blocked at v1)
  • data:image/* URIs for inline images

At v1 the sandbox iframe never executes the user’s JS field — it’s stored on the body but the sandbox is composed so scripts in the body can’t run regardless. There is a small, Dock-shipped wrapper script that runs inside the iframe to report the body’s height, capture clicks for the comment popover, and render comment markers — never user code. The two compositions:

  • Preview-only (no comments, no markers): sandbox is allow-same-origin only — no scripts. The safest default.
  • Capture or markers active: sandbox is allow-scriptsonly (never combined with allow-same-origin). Opaque origin means scripts in the body can’t reach *.trydock.ai cookies, localStorage, or any other Dock surface even if a sanitizer slip leaked a tag. The production-path renders from render.trydock.ai for cross-origin defense in depth.

An interactive mode that opts a workspace into running the user’s JS is queued for a follow-up.

REST

The same operations are available as REST endpoints:

  • GET /api/workspaces/:slug/html?surface_slug=...
  • PUT /api/workspaces/:slug/html with body { surface_slug?, html?, css?, js? }