---
name: agent-frame
description: Use when the user wants something shown graphically rather than described — "show me", "draw", "paint", "sketch", "render", "plot", "chart", "diagram", "visualize", "mock it up", "put it on the frame", "throw it on the screen" — or when a picture beats prose (progress dashboards, UI mockups, charts, rendered results, photos). Requires an agent-frame server; the user supplies a frame URL and write key.
---

# Skill: agent-frame — show the user things graphically

You have access to an agent-frame server: a live **frame** in the user's
browser that you can **update** at any time. Use it whenever a picture beats
prose — diagrams, charts, progress dashboards, UI mockups, rendered results.

## Vocabulary

The API has five concepts. Use these words when you talk *about* the
service, and expect them in every layer (HTTP, JS, CLI, viewer UI):

- **frame** — one named surface at `http://<host>/<frameId>`. The only noun.
- **content** — what's displayed. **update** replaces all of it (`POST /<id>`);
  there are no layers, objects, or partial writes. `GET /<id>/raw` reads it back.
- **overlay** — one string layered over the content (`POST /<id>/overlay`),
  for saying what you're doing while you work. Setting it replaces it; the
  next update clears it. It is *not* a message log or a notification
  channel — if it should stick around, put it in the content.
- **viewers** — the live browser sessions displaying this frame, each
  reporting its screen shape. Usually one; handle several.
- **watch** — subscribe to updates over the websocket (`/<id>/ws`).

Users will *not* speak this way, and that's fine. Treat all of these as the
same request — do not ask which one they mean, and don't correct their words
back at them:

> paint / draw / sketch / render / plot / chart / diagram this · show me ·
> put it on the frame · display / visualize / illustrate it · make me a
> picture of… · pull up… · throw it on the screen · graph it · mock it up

Any of those means: compose something visual and `POST` it. Match the user's
metaphor in your reply ("drawing that now"); use the API words in code,
commit messages, and when explaining the service itself.

## Core loop

1. Pick a frame id (letters, digits, `-`, `_`; e.g. `build-status`). It is
   created by the first update.
2. Tell the user to open `http://<host>/<frameId>` in a browser (or they
   already gave you one — keep using it).
3. POST content to `http://<host>/<frameId>` whenever you have something to
   show. Every POST fully replaces the frame and pushes to open browsers
   instantly. Update as often as you like — it is cheap.

## Fast first update

For quick generated content (text, a simple SVG), the whole job is ONE shell
call — no /skill re-read, no viewers pre-fetch, no overlay:

    curl -s -X POST -H 'content-type: image/svg+xml' \
         -H "authorization: Bearer $FRAME_KEY" \
         --data '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
      <rect width="800" height="600" fill="#16161e"/>
      <text x="400" y="300" text-anchor="middle" dominant-baseline="middle"
            font-family="system-ui" font-size="96" fill="#e6e6f0">hello</text>
    </svg>' http://<host>/my-frame

- Skip `GET /<id>/viewers` before the first frame: every update response
  includes the live `viewers` array, so send it immediately and adapt on the
  next frame. An SVG with a `viewBox` scales to any screen; the only real
  gamble is light-vs-dark — default to dark, or embed a
  `prefers-color-scheme` media query in the SVG/HTML to make it self-adapting.
- Set an overlay only when composing will take more than a few seconds; for
  one-liners it is pure overhead.
- **Don't POST a placeholder.** No "frame ready", "waiting for input" or
  hello-world frame to prove the wiring works: a blank frame already says
  so, in the user's own theme. The first thing on the frame should be the
  first thing they asked for. (Skip the smoke test too — the update response
  tells you it worked.)
- **Assume the first frame is blind.** `viewers` is usually `[]` on your very
  first update — the user may not have the tab open yet — so the screen shape
  is unknown. Make that frame fluid and it doesn't matter: see below.

## Fit any screen

One frame gets displayed on laptops, phones, tablets and TVs, and nobody can
scroll a wall-mounted screen. Compose so it fits whatever it lands on:

- **SVG: use a `viewBox` and no fixed `width`/`height`.** That alone scales
  to any screen, which is why SVG is the best default.
- **HTML: fluid, single-column-capable, no fixed pixel layout.** A
  two-column grid at a fixed max width will overflow a portrait phone
  viewport. Prefer `clamp()` type, `%`/`fr`/`auto` tracks, `flex-wrap`, and
  `grid-template-columns: repeat(auto-fit, minmax(...))` so columns collapse
  on their own.
- **Never rely on scrolling.** If it doesn't fit, it isn't seen. Show less.
- Then use the `viewers` array in the update response to tighten the *next*
  frame — portrait wants vertical composition, monochrome (e-ink) wants
  high-contrast grayscale, a large viewport with `pointer: coarse` is
  probably a TV: 10-foot type, no hover, no fine detail.

## Content types

The body is stored verbatim and rendered in a sandboxed iframe, so any of
these work — just set the matching `Content-Type` header:

- `image/svg+xml` — best default: crisp, scriptable, tiny. Include
  `xmlns="http://www.w3.org/2000/svg"` and a `viewBox`.
- `text/html` — full pages; inline `<style>` and `<script>` allowed
  (sandboxed: scripts run, no top-navigation).
- `image/png`, `image/jpeg`, `image/gif`, `image/webp` — raw image bytes.
- `text/plain` — fine for quick output.
- `text/prompt` — the body is a *description*, not content ("a bird",
  "sunset over mountains, oil painting"). The server resolves it into an
  image and shows that: first a photo search (Openverse, with license
  credit in the response), falling back to on-platform image generation.
  `?mode=photo` or `?mode=generate` forces a lane. Generated images are
  cached, so repeat prompts are sub-second. For any photo/artwork ask,
  prefer this over fetching images yourself — it is one tiny call.

## The write key

Reads are never gated: the frame id is the read capability, so a frame URL
is safe to share, print or leave on a screen. Writes need the frame's key.

The key may reach you either way:

- **In the URL** — `http://<host>/my-frame?key=<key>`. This is what the setup
  paste-prompt hands out, because a single self-contained URL is all you need
  to make your first update with zero setup. Use it as-is if that's all you
  have.
- **As a header** — `Authorization: Bearer <key>`. This is the form to settle
  on.

After your first write, lift the key out of the URL and send it as the header,
using the plain frame URL from then on. Never echo a URL containing `?key=`
back to the user, into a log, or into frame content: they will click it,
screenshot it, or screenshare it, and the key is the write capability. (The
viewer strips `?key=` from its address bar for the same reason.)

A frame is claimed by its first write, with whatever key that write presents;
only that key works afterwards. A frame first written with no key stays open.

curl example:

    curl -X POST -H 'content-type: image/svg+xml' \
         -H "authorization: Bearer $FRAME_KEY" \
         --data @drawing.svg  http://<host>/my-frame

## JS API (`GET /client.js`)

    import { openFrame } from './client.js'   // or fetch it from the server
    const frame = openFrame('http://<host>/my-frame', { key: '...' })
    // or hand it the write URL; the key is moved into a header for you and
    // frame.url stays the plain, shareable read URL:
    // const frame = openFrame('http://<host>/my-frame?key=...')
    await frame.update(svgString, { type: 'image/svg+xml' })
    await frame.overlay('drawing clock')      // '' clears it
    const screens = await frame.viewers()     // [{ viewport, dpr, ... }]
    const { content, contentType } = await frame.get()
    const sub = frame.watch((update) => { ... })  // sub.close()
    frame.url  // give this URL to the human

## CLI (`cli.js`, alongside client.js in the repo)

    export FRAME_URL=http://<host> FRAME_KEY=...
    node cli.js update my-frame drawing.svg      # type inferred from extension
    echo '<svg ...>' | node cli.js update my-frame --type image/svg+xml
    node cli.js get my-frame
    node cli.js watch my-frame
    node cli.js url my-frame

## The one-call pattern

Users feel every second between asking and seeing pixels. Do the whole thing
in ONE tool call / shell invocation. For imagery, that call is just the
prompt — the server sets an overlay, searches/generates, verifies, and shows it:

    curl -s -X POST -H 'content-type: text/prompt' \
         -H "authorization: Bearer $FRAME_KEY" \
         --data 'a coffee pour-over, morning light' http://<host>/my-frame

If you fetch an image yourself anyway (a specific known URL), set an overlay
in the background first and sanity-check the bytes before sending:

    curl -s -X POST -H "authorization: Bearer $FRAME_KEY" \
         --data 'fetching a photo' http://<host>/my-frame/overlay &
    curl -sL -o /tmp/img.jpg 'https://example.com/exact-image.jpg'
    file /tmp/img.jpg | grep -q JPEG && \
      curl -s -X POST -H 'content-type: image/jpeg' \
           -H "authorization: Bearer $FRAME_KEY" \
           --data-binary @/tmp/img.jpg http://<host>/my-frame

Notes on the pattern:

- Prefer raw curl over the CLI on the hot path: node startup costs ~120ms
  per invocation, curl ~15ms. The CLI is for humans and for stateful use
  (`watch`); the endpoints are simple enough that curl needs no wrapper.
- The `&` matters: the overlay appears while the fetch is still running.
- Always sanity-check fetched bytes (`file ... | grep -q JPEG`) before
  sending — a 404 page served as image/jpeg shows a broken image.
- Don't fetch stock photos by remembered URL/ID — you'll show the wrong
  subject. Use `text/prompt` and let the server search/generate instead.
- Size remote fetches from the viewer's screen profile (see below): portrait
  viewport → `w=900&h=1200&fit=crop`, and scale by devicePixelRatio.
- Don't compose in your head first and post at the end. For simple generated
  content, skip the overlay and update in one call (see "Fast first update").
  For content that takes real time to compose or fetch, set an overlay in one
  quick call first, then write and update in the next.

## Tips

- **Overlay first.** Composing content takes you seconds; the user is
  watching. Before you start, fire `frame.overlay('drawing clock')`
  (or `node cli.js overlay <id> "drawing clock"`, or
  `POST /<id>/overlay` with a plain-text body) — a translucent pill appears
  over the frame, previous content still visible, and your next update
  clears it. Ask-to-first-pixels drops to near zero. Keep it to one short
  present-tense phrase; it's a status, not a running commentary.
- **Know your viewers.** `GET /<id>/viewers` (or `frame.viewers()`,
  or `node cli.js viewers <id>`) returns each live session's screen profile:
  viewport size, orientation, device pixel ratio, touch/pointer type,
  `colorScheme` (`'light'` / `'dark'`), monochrome (e-ink). Compose for the
  actual screen — portrait viewport wants vertical art, e-ink wants
  high-contrast grayscale. Update responses include the same `viewers`
  array, so every update refreshes your knowledge.
- **Match the viewer's light/dark.** The page chrome already follows the
  system theme; your content should too, or it will glare. Two options, both
  fine:
  - *Self-adapting* (best — survives the user flipping themes mid-session,
    and needs no pre-fetch): put the query inside your SVG/HTML and let the
    browser decide.

        <style>
          :root { color-scheme: dark light }
          .bg { fill: #16161e } .ink { fill: #e6e6f0 }
          @media (prefers-color-scheme: light) {
            .bg { fill: #ffffff } .ink { fill: #16161e }
          }
        </style>

  - *Targeted*: read `colorScheme` from the `viewers` array and bake in the
    right palette. Necessary for raster output (PNG/JPEG can't adapt) and
    for the `text/prompt` lane.

  With several viewers on different themes, self-adapting content is the
  only thing that satisfies everyone; otherwise follow the majority.
- One frame per topic; reuse the same id to animate/update in place.
- The response to a POST includes `viewers` — the live sessions and their
  screens, so every update refreshes what you know about the display.
- `GET /<frameId>/raw` returns the current content (useful to build on
  your own previous frame).
