Skip to content
Back to skills

App Authoring

ASecurity

Authoring contracts for interactive artifacts, app dashboards, and Dock panels — display_ui backchannel and theme events, pin_app action buttons (fire_task / send_prompt / mcp_tool / platform, per-action min_role), live data panels, the platform catalog (otodock.feed / otodock.platform), live apps (app_state / app_push / open_app), releases and rollback_app, what a shared app or link viewer sees, scoped dashboards, file pins, and apps with a server — the folder contract, handlers the platform...

  • 190 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 29, 2026
ai-agentspythonrustgobashsqlnodeawsgitapidatabase

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 7 files and shows the line behind each finding

Scanned October 7, 2026

npx -y skills add OtoDock/oto-dock --skill app-authoring --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of App Authoring?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for App Authoring
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/otodock-app-authoring/badge)](https://www.skillsdirectory.com/skills/otodock-app-authoring)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: app-authoring
description: Authoring contracts for interactive artifacts, app dashboards, and Dock panels — display_ui backchannel and theme events, pin_app action buttons (fire_task / send_prompt / mcp_tool / platform, per-action min_role), live data panels, the platform catalog (otodock.feed / otodock.platform), live apps (app_state / app_push / open_app), releases and rollback_app, what a shared app or link viewer sees, scoped dashboards, file pins, and apps with a server — the folder contract, handlers the platform wakes, bindings between apps, .otoapp bundles and the widget kit. Use whenever building or updating a display_ui artifact, app, dashboard or app server.
---

# App & artifact authoring

The display-tools skill covers WHEN to reach for these surfaces; this one is
the full authoring reference. Everything here renders in the same sandboxed,
theme-matched frame (token vars, primitives, and kit script tags are listed
on the `display_ui` tool itself).

## `display_ui` authoring

**Chat backchannel** — `window.otodock.send(payload)` delivers an interaction back into the chat, and YOU receive it as a new framed input (`[interaction from artifact "title"]` + the JSON payload) — sent immediately when the chat is idle, queued to the turn boundary while you're working. Use it for real decision points: an "Analyze this row" button, a confirm/apply choice, a form the user fills. Rules: send ONLY on an explicit user gesture (never on load — every delivery starts a real agent turn and is rate-limited), keep payloads small self-describing JSON (≤8KB, e.g. `{"action":"analyze","row":"2026-03"}`), and reflect delivery state from the ack — the host answers with an `otodock:action-ack` window event whose `detail.status` is `sent` / `queued` / `blocked` (user declined the first-use consent) / `denied` (rate/size) / `unavailable` (read-only views like history or task runs — disable the button there):

```html
<button class="btn primary" id="go">Analyze March</button>
<script>
  document.getElementById('go').onclick = function(){
    window.otodock.send({action: 'analyze', month: '2026-03'});
  };
  addEventListener('otodock:action-ack', function(e){
    var b = document.getElementById('go');
    if (e.detail.status === 'sent' || e.detail.status === 'queued') b.textContent = 'Sent ✓';
    else if (e.detail.status === 'unavailable') b.disabled = true;
  });
</script>
```

**Links** — a plain `<a href="https://...">` works: the runtime bridges ABSOLUTE http(s) links to the host, which opens them in a new tab after validation and a one-time per-artifact consent (the chip shows the destination site). Write real absolute URLs: relative paths and platform (same-origin) URLs never open — the sandbox has no navigation and platform routes are deliberately refused — and clicking one is cancelled, so it can't break your page either. In-page anchors (`#section`), `mailto:`/`tel:` links and `download` links behave normally. Each attempt is acked with an `otodock:open-url-ack` window event (`detail.status`: `opened` / `blocked` / `denied`, e.g. popup-blocked or rate-limited) if you want to reflect state. Prefer `otodock.send(...)` action buttons for anything that should involve YOU; links are for genuinely external destinations.

**Full documents** — content starting with `<!doctype`/`<html` keeps its own markup and `<head>` (no tokens CSS / ui-kit), but the platform runtime is still injected before `</body>`, so theme events, auto-height, actions, feeds and links all keep working.

**Design responsive** — the artifact renders on phones as well as desktop: fluid widths only (`width:100%` / `max-width`, never fixed pixel layouts), let content wrap, use Tailwind responsive variants (`sm:` `md:`) for multi-column layouts that must collapse on narrow screens, and give JS-drawn charts a percentage width with a sensible fixed height. On desktop the app surface is the FULL tab width (wide, roughly 16:9) — design for wide: multi-column grids and side-by-side panels that collapse on mobile, never one narrow column floating in empty space. If a section is long-form reading where a website-like measure genuinely reads better, self-cap just that section (`max-width` + `margin-inline:auto`) — the platform never caps your width; responsiveness is on you. And keep your OUTERMOST horizontal padding near zero (0–4px): the host frame already insets the page ~10px from the phone's screen edge, so heavy wrapper padding makes the dashboard read narrower than the platform's own chrome (the composer card sits at 12px). In grid/flex layouts use FIXED gaps and padding (`px`/`rem`, never `%`) — percentage row-gaps in auto-height grids mis-resolve and make content overlap. And keep the html LEAN: every KB is time the user spends watching you generate — aggregate data before embedding it, skip decoration that the tokens CSS already provides.

**Styling judgment**: load Tailwind BY DEFAULT and compose utilities over the token vars (`bg-[var(--p-surface)]`, `text-[var(--p-primary)]`) so custom styling stays native in both themes — the polish is what makes artifacts impressive, and the modest extra output tokens are worth it. Skip it only for the simplest cases — a single plain card, table, or chart — where the token primitives alone already look native and generate fastest.

**Theme contract**: the frame sets `.dark` on `<html>` and fires a `otodock:theme` window event on live theme switches. Read colors from the CSS vars, and re-render JS-drawn visuals on that event. The ECharts idiom:

```html
<div class="card"><h3>Weekly sessions</h3><div id="c" style="height:320px"></div></div>
<script src="/ui-kit/echarts.min.js"></script>
<script>
  function cssVar(n){ return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
  function render(){
    var chart = echarts.init(document.getElementById('c'));
    chart.setOption({
      textStyle:{ color: cssVar('--p-text') },
      xAxis:{ type:'category', data:['Mon','Tue','Wed'], axisLabel:{color: cssVar('--p-text-secondary')} },
      yAxis:{ type:'value', splitLine:{lineStyle:{color: cssVar('--p-border-light')}} },
      series:[{ type:'bar', data:[120,132,101], itemStyle:{color: cssVar('--p-primary')} }]
    });
    window.__c = chart;
  }
  render();
  addEventListener('resize', function(){ window.__c && window.__c.resize(); });
  addEventListener('otodock:theme', function(){ window.__c.dispose(); render(); });
</script>
```

**3D (`/ui-kit/three.min.js`)**: global `THREE` including a curated addon set — `OrbitControls` + `MapControls`, the post-processing chain (`EffectComposer`, `RenderPass`, `UnrealBloomPass`, `OutputPass`, `ShaderPass`), fat lines (`Line2`, `LineGeometry`, `LineMaterial`) and `RoundedBoxGeometry`. A living 3D scene is the kit's signature move for dashboards that should IMPRESS: an animated hero panel above the numbers, a glowing service graph, a slowly rotating data sculpture, a floor plan you can pan. Reach for it whenever motion or space adds real life to a dashboard — but don't force it on a surface that is honestly just a bar chart. The sandbox sets HARD limits, so design within them from the start:

- **No asset loaders.** `connect-src` is 'none' (an app with a server gets its own `/api`, `/platform` and `/ws` paths and nothing else, so loaders fail there too) — `GLTFLoader`, `FileLoader`, `OBJLoader` and every fetch/XHR-based loader fails silently-looking. No model files: build scenes from procedural geometry (`BoxGeometry`, `SphereGeometry`, `BufferGeometry` from typed arrays). `TextureLoader` works ONLY with `data:`/`blob:` URLs — canvas-generated `CanvasTexture` is the idiom.
- **No workers, no WASM** (DRACO/KTX2/meshopt decoders are out), **no WebXR**. Plain WebGL/WebGL2 rendering works fine.
- Guard init: `new THREE.WebGLRenderer()` inside try/catch with an HTML fallback message — some WebViews have no WebGL.
- **The canvas is fluid**: width follows the container, height comes from a CLAMPED style — `height:clamp(240px, 36vh, 460px)` is the house default for a hero panel. NEVER a bare `aspect-ratio` (a 16/6 hero that looks right on a laptop grows monstrous on a 32" desktop — the 2D cards around it stay CSS-px sized, and the 3D must too). A `resize` handler is MANDATORY — update renderer size, `camera.aspect` + `updateProjectionMatrix()`, and (when used) `composer.setSize(w,h)` and every `LineMaterial.resolution` (see below). The frame resizes when the tab or device does; a 3D panel that skips this renders stretched or letterboxed.
- **Frame for physical size, not screen fraction**: on a big canvas, pull the camera back so objects read laptop-sized instead of scaling up with the panel — in the resize handler, scale the camera's distance by `Math.min(1.5, Math.max(1, Math.sqrt(w * h / (1100 * 380))))` (reference ≈ a laptop-width hero). The platform's own 3D company map frames this way; a scene that skips it is the #1 "looks great on my laptop, huge on the office monitor" complaint.
- Theme: read colors from the CSS vars like the ECharts idiom; on `otodock:theme` update materials in place (`mat.color.set(cssVar(...))`) — no renderer rebuild.
- Mobile: touch just works on both control types — `OrbitControls` for orbit-a-scene, `MapControls` for pan-first surfaces (floor plans, boards). Cap `setPixelRatio(Math.min(devicePixelRatio, 2))`, scale object counts down on small screens (`matchMedia('(max-width: 640px)')`), and honor `prefers-reduced-motion`: keep interactivity but skip self-animation.

The impressive idioms, all sandbox-safe:

- **Bloom glow** — emissive materials + `UnrealBloomPass` (strength ~0.4–0.8) is the single biggest visual lever; it's what makes dark dashboards look alive.
- **Fat lines** — network edges and flows drawn with `Line2` + `LineMaterial({ linewidth: 2-4 })`, impossible with 1px `Line`. FOOTGUN: `LineMaterial` needs `material.resolution.set(w, h)` at init AND in the resize handler, or the lines render at the wrong width.
- **Instancing** — hundreds+ of similar objects go in ONE `InstancedMesh` (per-instance color via `setColorAt`), never N meshes; this is the difference between 60fps and a slideshow.
- **Data textures** — draw heatmaps, gradients and labels onto an offscreen canvas and wrap it in `CanvasTexture`; that plus procedural geometry replaces every blocked loader.

```html
<div class="card"><div id="v" style="width:100%;height:clamp(240px,36vh,460px)"></div></div>
<script src="/ui-kit/three.min.js"></script>
<script>
  var el = document.getElementById('v');
  try {
    var renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    el.appendChild(renderer.domElement);
    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
    var CAM_DIR = new THREE.Vector3(0, 2, 6);
    camera.position.copy(CAM_DIR);
    function cssVar(n){ return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
    var mat = new THREE.MeshStandardMaterial({ color: cssVar('--p-primary'),
      emissive: cssVar('--p-primary'), emissiveIntensity: 0.35 });
    var mesh = new THREE.Mesh(new THREE.RoundedBoxGeometry(2, 2, 2, 4, 0.25), mat);
    scene.add(mesh);
    scene.add(new THREE.AmbientLight(0xffffff, 0.7));
    var key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(3, 5, 4); scene.add(key);
    var composer = new THREE.EffectComposer(renderer);
    composer.addPass(new THREE.RenderPass(scene, camera));
    composer.addPass(new THREE.UnrealBloomPass(new THREE.Vector2(1, 1), 0.55, 0.8, 0.75));
    composer.addPass(new THREE.OutputPass());
    var controls = new THREE.OrbitControls(camera, renderer.domElement);
    function size(){ var w = el.clientWidth, h = el.clientHeight;
      renderer.setSize(w, h); composer.setSize(w, h);
      camera.aspect = w / h; camera.updateProjectionMatrix();
      // Physical-size framing: bigger canvas → camera farther back.
      var vs = Math.min(1.5, Math.max(1, Math.sqrt(w * h / (1100 * 380))));
      camera.position.copy(CAM_DIR).multiplyScalar(vs); }
    size(); addEventListener('resize', size);
    addEventListener('otodock:theme', function(){
      mat.color.set(cssVar('--p-primary')); mat.emissive.set(cssVar('--p-primary')); });
    var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
    (function tick(){ requestAnimationFrame(tick);
      if (!still) mesh.rotation.y += 0.004;
      controls.update(); composer.render(); })();
  } catch (e) { el.textContent = '3D not available on this device.'; }
</script>
```

The Edit-then-refresh loop (edit the saved file, re-call `display_ui` with only `save_path`) is described in the display-tools skill; the same loop works for apps — edit `apps/<slug>.html`, then re-pin the slug (`pin_app` with no `html`) — open tabs live-reload. For an app with a server the loop is edit `apps/<slug>/`, then `deploy_app(slug)` ("Apps with a server" below).

## The bar: an app people want to open

Every surface here — an artifact, a pinned dashboard, an app with a server — is judged as a product, not a printout. The platform renders every deploy and `check_app` at phone (390), tablet (820) and desktop (1280) widths and in the other theme, and `screenshot_app(slug)` shows you the same four pictures on demand: LOOK at them, phone first, before you tell the user it is done. What separates an impressive app from a plain one:

- **Native, in both themes.** Build on the token vars (`--p-bg`, `--p-surface`, `--p-text`, `--p-text-secondary`, `--p-primary`, `--p-border-light`, …) and the primitives (`.card`, styled tables, `.btn`), with Tailwind utilities composed over them (`bg-[var(--p-surface)]`), never a hard-coded palette. Read colors for JS-drawn visuals through `getComputedStyle` and redraw on `otodock:theme`. The dark picture must read as well as the light one: no white boxes, no invisible borders, contrast from the tokens.
- **One column on the phone, a grid from 820, the full width on desktop.** Fluid widths only; `grid-cols-1 md:grid-cols-2 xl:grid-cols-3` for panels; tables get `overflow-x:auto` INSIDE their card (the page itself never scrolls sideways) or collapse to stacked rows on narrow screens; tap targets 40 px or larger; the first phone screen shows the thing the user opens the app for, not a header and whitespace.
- **A header that orients.** Title, a status word ("Live", "Reconnecting…", "Updated 2 min ago") and at most one primary action, sticky when the page is long. Navigation between views as tabs or a segmented control, not a sidebar on a phone.
- **Every state designed.** Empty: what this is and the one action that fills it (the render viewer is a synthetic member with empty personal feeds, so the empty state is the first thing the platform sees). Loading: a skeleton in the layout's shape, not a spinner in a void. Error: what failed in words and a retry. Stale: the last update time and the socket state. Refusal: what the viewer's role cannot do, said once.
- **Numbers and text that scan.** Units, thousands separators, right-aligned numerals (`.num`), relative times with the exact time on hover, one decimal at most, truncation with `title`, never raw JSON or ids in the UI. A chart only when the shape of the data needs one (the ECharts idiom above), with the same tokens.
- **Feedback on every action.** A pressed button shows its busy state and acknowledges (`otodock:action-ack`; a server write updates the view optimistically and reconciles on the socket push, the way the kanban template does); a failure lands next to the control that caused it, not in a banner at the top.
- **Motion that explains, not decorates.** 150–200 ms transitions on state changes, nothing that loops, `prefers-reduced-motion` respected, no layout shift as data arrives (reserve the space).
- **Accessible by construction.** Real buttons and inputs with labels, visible focus rings, Enter on the input and Escape on the dialog wired in script (there is no `<form>` submit in the sandbox), headings in order, live regions for counts that change.
- **The loop.** `check_app` → fix everything under "Worth fixing" → `screenshot_app` → read the four pictures like the user would (is the phone picture complete? is the dark one legible? does the empty state say what to do?) → `deploy_app`. The pictures reveal the usual failures: a table cut off at the phone edge, a header taller than the content, twelve-pixel text, a chart with no axis labels, an empty first screen. Fix, look again, then hand it over.

## Pinned apps (`pin_app` / `unpin_app` / `list_apps` / `rollback_app`)

An app is a STANDING dashboard, pinned once and opened any time from the apps button on the chat page — it outlives every chat. Pin one when the user wants a recurring surface (morning brief, project status board, finance dashboard, home-control panel); a standing in-chat artifact covers "for this conversation", `pin_app` covers "for every visit". Once pinned, keep it fresh with a scheduled task instead of rebuilding on request.

**Who sees it — `visibility`** (orthogonal to `scope`, which picks WHERE it lives): `visibility="agent"` pins ONE shared dashboard for every user of this agent (its file lives in the shared `workspace/apps/`; needs editor+ when a human drives the session); `visibility="user"` pins the current user's personal dashboard (`users/<u>/workspace/apps/`). Omitted, it follows the agent's mode default — the tool schema shows this agent's default. Match intent: "pin this for the whole team" → `"agent"`; "just for me" → `"user"`. The same slug may exist in BOTH visibilities (two rows, two files) — re-pin with the SAME visibility to update; `unpin_app` auto-detects unless the slug is in both (then pass `visibility`). Users can hide a shared app from their OWN strip without affecting the team — that per-user hide is theirs, not yours; a re-pin never un-hides it for them.

- **Authoring is the same sandbox as `display_ui`** (body fragment, tokens CSS auto-loaded, kit scripts available) with two HARD requirements because apps are seen daily on every device: load Tailwind and design mobile-responsive.
- **Updating**: re-pin the same slug (check `list_apps` first; the apps it lists under "Placed here" are another agent's, placed by a share: call their exported methods by id, never re-pin them) — open tabs live-reload; that is also how a scheduled task refreshes an app every morning. Editing the workspace file alone does not notify open tabs — and viewers are never served the file you edit: every pin cuts a **release** (a copy of the page; the pin ack names its number) and that copy is what every screen shows, so a half-written file is never seen.
- **Rolling back — `rollback_app(slug)`**: the last three releases are kept. When a re-pin broke something and the fix will take a moment, `rollback_app` switches every open screen to the release before the current one, at once. The working file `apps/<slug>.html` is untouched, so the next `pin_app` cuts a fresh release from it; `no previous release` means there is nothing to go back to. For an app with a server the database goes back too — restored from the copy taken before the current release went live, the newer writes kept in a snapshot beside the releases — and the tool is refused while a release waits for approval.
- **Restoring after an unpin — never rebuild**: the dashboard's X only HIDES an app (`list_apps` shows the slug marked `unpinned`); `pin_app(slug)` alone brings it back with its file, actions and approval intact. Even after your own `unpin_app` (the hard remove), the workspace file survives at `apps/<slug>.html` — `pin_app(slug, actions=[…])` re-registers over it without resending html. An app with a server: `pin_app(slug)` on the surviving folder IS a deploy (`actions` is ignored — `app.json` carries them), and its database under the platform's `app-data/` survives the unpin, so the app comes back with its data.
- **Action buttons** — declared, never free-form. Declare the manifest in `actions`; the USER must approve it before any button works (the pin ack tells you `pending user approval` — say so). In the app, invoke by id:

```html
<button class="btn primary" onclick="otodock.action('refresh')">Refresh now</button>
<button class="btn" onclick="otodock.action('analyze', {month: '2026-03'})">Analyze March</button>
```

  - `{"id":"refresh","label":"Refresh now","type":"fire_task","task_id":"<id>"}` fires an existing task VERBATIM (no args); add `"checks": ["coding"]` to attach the agent's checks to the run the press starts (the `checks` skill; the card names them, a verdict that does not pass fails the run). Create a `task_type='trigger'` task (schedules-mcp) as the canonical button target — a `one_time` task would delete itself after the first click and is rejected. Add an `args_schema` (below) and the validated args substitute into the task's `{{placeholders}}` at fire time.
  - `{"id":"analyze","label":"Analyze month","type":"send_prompt","prompt":"Analyze my {{month}} spending"}` delivers the prompt into the chat the user has open (framed as an app action, like the artifact backchannel; on the front page it starts a new chat). `{{key}}` placeholders fill from the `otodock.action` args.
  - `{"id":"lights","label":"Toggle office lights","type":"mcp_tool","mcp":"ha-mcp","tool":"toggle_light","fixed_args":{"entity":"light.office"},"args_schema":{"type":"object","properties":{"brightness":{"type":"integer","minimum":0,"maximum":255}}}}` calls ONE tool on one of YOUR OWN MCPs directly — no agent turn, no token spend: the click IS the tool call, and its result comes back to the page. Use it for instant controls (toggle a device, refresh a value); use `fire_task`/`send_prompt` when the work needs an agent thinking. `mcp` is the namespace segment from your own tool names (`mcp__display__…` → `"display"`); local MCPs only — device/satellite MCPs are rejected. `fixed_args` are baked in verbatim (the page can never change them); `args_schema` is a FLAT object of scalar props gating what the page may pass — every string needs `maxLength` (or `enum`), keys must not overlap `fixed_args`, and anything outside the schema is refused server-side. Schema-less actions accept NO args. **The account a tool wants named is filled by the platform**: a value of `"${account.email}"` (in `fixed_args`, or passed by the page) becomes the email of the account the button runs with — the agent's service account on a shared app, the owner's on a personal one — so a Google tool's `user_google_email` is `"fixed_args": {"user_google_email": "${account.email}"}` and NEVER a Settings field the viewer types into; nothing connected → the tool's own error says so (show it, and the `connect` widget below).
  - `{"id":"me","label":"Who is viewing","type":"platform","method":"viewer.me"}` declares a platform METHOD — data the platform answers as the viewer, no MCP, no agent turn. The page calls it with `otodock.platform('viewer.me')`, which returns a Promise (resolves with the answer, rejects with the reason); `otodock.action()` on a method id is refused — methods are called, not fired. Methods (plus `files.list` / `files.read` / `files.write` for an app whose `app.json` declares a `files` block — "Apps with a server" below; the block names the prefixes, and EACH file method the page, the server or a step calls must ALSO be declared as a `platform` action like any other method, e.g. `{"id":"publish","label":"Publish the counts","type":"platform","method":"files.write"}` — an undeclared one answers 404, and the card lists them under Platform data): `viewer.me` → `{sub, name, username, role: viewer|contributor|editor|manager|admin, external: false}` (`role` is the one your `min_role` floors judge this viewer at: for someone the app was shared with, the share's role; greet by name, hide a manager-only panel); `integrations.status` → `{providers: [{mcp, provider, connected, account, identity}]}` (whether the account the app's BUTTONS run with exists — `identity` is `you` / `the owner` on a personal app, `the agent` on a shared one, `account` its email — so a personal app pairs a missing one with `otodock.open({kind:'user_settings', tab:'integrations', provider})` and a shared app with `{kind:'agent_settings', tab:'mcps'}` for a manager; the `connect` widget does both); `tasks.run_result` with args `{run_id}` → `{id, status, output_text, error_message, started_at, completed_at}` (a run the viewer may see; `output_text` up to 32 KB); `notifications.create` with args `{title, body?, severity?: info|success|warning|danger}` → a notification to the viewer themself (their toast and inbox); `app.audience` → who uses the app ("Know your audience" under Sharing below; a page reads it only for an editor or manager of the app's own agent, the owner of a personal app, or an admin; a floor you declare below editor is raised to editor, so the card says at least "editors and up"); `viewer.data.read` → `{doc, rev}` and `viewer.data.write` with args `{doc}` (replace) or `{patch}` (merge, `null` deletes a key) → `{doc, rev}`, the viewer's own document in a single-file app ("A document per viewer" below; declare the two without an `args_schema`: a schema holds scalars only, and the platform holds the document to its limits itself). An `args_schema` is optional on a platform method: without one the page's args reach the method as sent and the method checks them itself (the booking template's `notifications.create` declares none); with one (scalar props only, like `mcp_tool`) any arg outside it is refused. Four calls per second per viewer; every call settles.
  - **`min_role` on any action** — `"min_role":"contributor"`, `"editor"` or `"manager"` (omit it for every viewer; `"viewer"` is the same as omitting it). The button, feed or method then works only for viewers with at least that role on the agent; everyone else gets `denied`, and the approval card names the floor ("contributors and up" / "editors and up" / "managers only"). Floor what CHANGES things and leave reads open: "Save to the shared workspace" contributor (the role that writes files but never acts as the agent), "Restart service" editor, "Refresh stats" for everyone. The owner of a personal app is its manager; someone an app was shared with acts at the role the share gave them (viewer unless the sharer picked higher; an app placed in another agent by a share gives its members that role capped by their own there); a call that reaches the platform through a prompt or an API key is always a viewer, whatever the account's role.
  - Acks arrive as the same `otodock:action-ack` window event (`sent` / `queued` / `denied` / `unavailable`; mcp_tool resolves to `done` / `error` when the tool finishes) — reflect button state.
  - mcp_tool results ALSO arrive as an `otodock:action-result` window event: `detail = {id, ok, result, call_id}` (result is the tool's text output, truncated at 32KB). Render it into the page — e.g. update a status badge or value the button controls. EVERY mcp_tool invocation ends in exactly one action-result — refusals too (`ok:false` with the reason as `result`: rate-limited, network error, tool unavailable), so keying ALL busy/spinner state off this event is safe and correct.
  - **Calls batch by themselves**: `otodock.action(id, args)` returns a `call_id`, and every call made in the same tick (a panel refreshing its four widgets on load) goes to the platform as ONE request; results come back per call as they land, so match `e.detail.call_id` when one action serves several widgets (keying on `id` alone still works for a single widget). Batches are paced to one per second — a call inside the window waits for the next batch, it is never refused — so fire what a view needs together and show busy state until each result arrives.
  - **Busy state is REQUIRED on every mcp_tool control**: the first call after a quiet period warms the tool's MCP (up to ~10s), and a control that looks dead gets pressed again — for a TOGGLE that means fire-twice-back-to-original. On press: disable the control + show a spinner/label change immediately; re-enable on the `otodock:action-result` for that id (an `ok:false` result still terminates the call — show its `result` text briefly). One in-flight call per action+args is enforced server-side — the disabled state is how the user sees it.
  - **Controls beyond buttons — sliders, selects, steppers**: any value control maps naturally to an mcp_tool action with the value declared in `args_schema` (e.g. a brightness slider → `{"type":"mcp_tool","mcp":"ha-mcp","tool":"set_brightness","fixed_args":{"entity":"light.desk"},"args_schema":{"type":"object","properties":{"brightness":{"type":"integer","minimum":0,"maximum":255}},"required":["brightness"]}}`). Fire on **`change`** (release), never on `input` while dragging — rate limits eat mid-drag floods — and reflect the confirmed value from the action-result, not the optimistic one. Prefer a slider over an on/off button wherever the underlying tool takes a level.
- **Navigation — `otodock.open(target)`**: a button can move the viewer to a platform page. Name a KIND and an id, never a URL (platform URLs are refused everywhere, see Links); the host builds the route and the destination enforces its own access. Kinds: `{kind:'chat', id}` (a chat of this agent; add `agent` for another agent's chat), `{kind:'run', id}` (a task run), `{kind:'app', id}` (another app, full screen), `{kind:'agent'}` / `{kind:'agent', agent}` (an agent's page), `{kind:'agent_settings', tab}` (tab: `scheduled-tasks` `triggers` `notifications` `meetings` `conversations` `config` `mcps` `skills`), `{kind:'user_settings', tab, provider?}` (tab: `general` `integrations` `remote-machines` `ai-engines` `audio` `usage`; `provider` = an MCP name — the Integrations tab scrolls to and highlights that row, the user then connects it with a click: the way an app that ships with a community agent offers "Connect Gmail" without ever starting a consent flow itself), `{kind:'file', path}` (a workspace file in the workspace overlay). Rules: only on a real click (a navigation on load is refused), a short burst limit shared with links, the two settings kinds ask the user once per app (Allow/Block chip), and it needs the app's approval like every other channel. Acked as an `otodock:open-ack` window event (`detail.status`: `opened` / `denied` / `blocked` / `unavailable` on read-only views — disable the button there).

```html
<button class="btn" onclick="otodock.open({kind:'chat', id: row.chat_id})">Open the chat</button>
<button class="btn" onclick="otodock.open({kind:'user_settings', tab:'integrations', provider:'gmail-mcp'})">Connect Gmail</button>
```

- **Every app has a URL**: the app's three-dot menu opens it full screen at `/apps/<id>` (the page keeps live reload, feeds and buttons); `{kind:'app', id}` above navigates there too. The id is the `app_id` the pin ack returns.

## Live apps (`app_state` / `app_push` / `open_app`)

A re-pin changes an app; these change what an OPEN app shows while the user watches, and put an app in front of them. None needs approval: you author every byte of the page.

- **State document — the data of the page.** Keep the LAYOUT in the HTML and the DATA in the app's state: one JSON object per app that the platform stores. `app_state(slug, patch={...})` merges a change (objects merge key by key, `null` deletes a key, anything else replaces); `app_state(slug, doc={...})` replaces the whole document. Every open page gets the new document within a second and a page opened tomorrow gets it on load — no re-pin, no server. Up to 64 KB, two writes per second. Only you write it: when a viewer should change something, give them a button and update the state from the action. It is ONE document for every viewer: what a person keeps for themselves (their settings, their notes) goes in their own slice, `viewer.data.read` / `viewer.data.write` ("A document per viewer" under Sharing; single-file apps only), never in the state. Render FROM the state and bake the last known values into the HTML so the page is never empty:

```html
<div class="card"><span id="phase">not started</span></div>
<script>
  function render(doc){
    document.getElementById('phase').textContent = doc.phase || 'not started';
    (doc.lanes || []).forEach(function(l){ /* ... */ });
  }
  addEventListener('otodock:state', function(e){ render(e.detail.doc); });  // fires right after load AND on every write
  addEventListener('load', function(){ if (otodock.stateRev >= 0) render(otodock.state); });
</script>
```

  `otodock.state` is `{}` (and `otodock.stateRev` is -1) until the first delivery, which arrives right after your page's `load`. Listen for the window event rather than calling `otodock.onState` at parse time: in a full document the runtime lands after your own scripts. Never keep the document in a variable across events; read `otodock.state` again.
- **Push — show, point, update now.** `app_push(slug, payload)` relays any small JSON to every open frame at once and stores nothing (a viewer who opens the app later sees nothing of it). For "look at lane three", a highlight, a value that just changed. In the page: `otodock.onPush(function(p){ ... })`, or the `otodock:push` window event (`e.detail.payload`). Up to 32 KB, ten per second. When you change the thing an app shows through your own tools (you opened the gate, you finished the task), push the new value so the page reflects it at once instead of waiting for its next refresh.
- **Open — "have a look at this".** `open_app(slug)` puts the app on the user's screen: in the chat they are in it opens right away (the overlay, or the Dock for a chat-scoped app); anywhere else they get a notice with an Open button. Use it after you built or changed an app they asked for, not on every turn. The result says `opened`, `no_screen` (no screen in use right now), `hidden` (they parked the app off their strip) or `off` (they turned this off in their settings): say so and move on, never retry. An app a share placed in this agent opens too: `open_app(slug, agent=<its home agent>)` when one of this agent's own apps, or an app placed here from another home agent, shares the slug (`list_apps` names the home agent; without `agent` such a call answers 400 naming both); it arrives as the notice with Open, never in this agent's overlay, and `hidden` then means hidden in this agent.
- **You know what they look at.** While an app is on the user's screen, your turn starts with a line naming it (`[The user is looking at the app "Title" (slug) right now.]`), so "update this" or "what does lane three mean" needs no explanation; no line means the chat or the home page is on screen. The line names the slug alone: when `list_apps` lists that slug under "Placed here" and not among this agent's own apps, the app is another agent's (call its exported methods, never re-pin it); when it lists both, ask which one. The user can turn this off in their settings.

## Apps with a server (`apps/<slug>/`)

When several people must WRITE the same data — a board everyone moves cards on, a sign-up sheet, an inventory, a shared counter — a single-file app is the wrong shape: its state document has one writer (you). Build a **folder app** instead: `apps/<slug>/` with an `app.json`, a `client/` the frame renders, and a `server/` the platform runs for you on Bun, in its own sandbox, with its own SQLite. Everyone who opens the app talks to that server through the app's own API; you reach the same API from your session. Keep single-file apps for everything that is a VIEW (dashboards, briefs, controls) — a server is for shared, written data.

```
apps/kanban/
  app.json              {"title": "Kanban", "actions": []}  (+ files / egress / deploy_requires_approval)
  client/index.html     the page — same sandbox and kit as display_ui, relative asset paths
  server/index.ts       Bun.serve on 0.0.0.0:$PORT, bun:sqlite at $OTODOCK_DATA_DIR/app.db, GET /_health
```

Start from a template in this skill's `templates/` (copy the three files, rename the slug): `kanban/` is a working board with live updates and per-move attribution; `booking/` is a service with its own customers on a public link — accounts kept by the host, bookable slots, a Stripe Checkout payment through a declared secret, the vendor's event through an inbound hook, the owner told on the app's own identity, and the owner's view with the settings (the service, the price, the hours) — the worked example of every "outside" facility below. Both stay generic: the words are settings, not code. The rules that matter:

- **The server** listens on `0.0.0.0` and the port in `$PORT`, opens its database at `$OTODOCK_DATA_DIR/app.db` (the ONLY writable directory — `/app` is the read-only release; `node_modules` is never shipped, so use Bun's built-ins: `Bun.serve`, `bun:sqlite`, `fetch`), and answers `GET /_health` with any status below 500 within 20 s of start. It is started on the first request and stopped after ten idle minutes, so keep startup cheap and state in the database. Migrations are ADDITIVE ONLY (`CREATE TABLE IF NOT EXISTS`, `ADD COLUMN`; never drop or rename): during a deploy the previous release keeps serving the same file until the new one answers. `console.log` goes to the app's log (the user's menu → Logs; your `app_logs(slug)`).
- **Who is calling.** Every request the server receives came through the platform: `X-OtoDock-Viewer` is a signed claim (JWT-shaped; `username`, `sub`, `role` (`viewer`, `contributor`, `editor`, `manager` or `admin` — rank it, never match a fixed list), `external` for a link viewer, `principal: "agent"` when it is you — its `role` is the word `agent` when no person drives the session: rank it below viewer — and `principal: "placement"` for a session of another agent a share placed you in) and `X-OtoDock-Basis` says `viewer`, `agent`, `app`, `external`, `platform`, `inbound` (a vendor's event, "Inbound hooks" below), `binding` or `placement`. Basis `app` is the server calling its own API with its launch token (no `X-OtoDock-Viewer` header then: treat the missing header as "the app itself"); `platform` is the platform waking one of your handlers (the claim's `principal` is `platform`, no user behind it); `binding` is another app calling an exported method (no `X-OtoDock-Viewer`, but a signed `X-OtoDock-Caller` naming the calling app and the viewer's role on YOUR agent). Attribute those two explicitly, "the schedule" and "the &lt;caller&gt; app", never as a viewer. Basis `placement` is a session of an agent this app is placed in by a share ("Agents the app is placed in call its exports" under Sharing): the claim's `agent` is THAT agent, `placement {kind: agent|department|person, share_id, from_agent, agent, role_cap}` names the share and `from_agent` is your own agent (nothing else in your environment names it), `role` is already capped by the share, and `sub` / `username` name the person behind it when there is one (else `sub` is `session:<id>` and `username` empty); such a request reaches your `exports.methods` only, and its claim is never forwardable (the platform route and the broker refuse it). A claim with `principal: "placement"` (basis `placement`) is a session of another agent this app is placed in, never your own agent's; `principal: "agent"` and basis `agent` are your own agent's sessions alone. Attribute writes from it — never from a body field the page fills in. Nothing but the platform can reach the port, so decoding the payload is enough; verify the signature with `OTODOCK_APP_PUBLIC_KEY` (Ed25519) if you want to.
- **The page** calls its server with `otodock.fetch(path, init)` (`/cards`, never a host — the runtime adds the token and retries a starting server) and `otodock.ws(path)` for live updates; plain `fetch`/`WebSocket` cannot leave the sandbox. `otodock.ws` RECONNECTS BY ITSELF (its own backoff, the token re-sent on rotation, one wrapper per path) — never add a reconnect of your own in `onclose`: it doubles the sockets on every close until the per-viewer cap refuses them all and the app looks permanently offline. Use `onopen` / `onclose` to show the state and nothing else; `app_logs` reports refused sockets when a page gets this wrong. No `<form>` submits either: the frame is sandboxed without `allow-forms`, so a submit is blocked before your handler runs — wire the button's `click` and the input's Enter `keydown` in script (the template does). The page keeps every other app facility (state, push, feeds, `otodock.platform`, buttons, `otodock.sharedLink` — a link viewer gets the API and the socket, nothing else).
- **Network from the server:** only the hosts you declare in `app.json` `"egress": ["api.example.com"]` (public hosts only; the user approves the list on the card), plus DNS. Nothing on the LAN, no other app, no platform database. A declared host is opened by its address, so other sites on the same address (a shared CDN) answer too: never tell the user the egress list keeps data from leaving the app, and call vendors through the egress route below. For platform data use `otodock.platform` from the page, or from the server `POST $OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/platform/<method>` with `Authorization: Bearer $OTODOCK_APP_TOKEN` (forward the caller's `X-OtoDock-Viewer` header and the call runs as that viewer, or as the session's user when the caller is you; without it as the app itself, read-only; a placed agent's claim, basis `placement`, is refused there: answer such a caller from your own data). A method's `min_role` judges an agent session at that session's own row on the agent, `viewer` without one, whatever the person's platform role. Declare the methods as `platform` actions like a page would; `files.list` / `files.read` / `files.write` reach workspace paths you declare under `"files": {"read": ["workspace/reports/"], "write": ["workspace/reports/"]}` (at most 8 prefixes, 2 MB per file).
- **Ship it — `deploy_app(slug)`** (`pin_app(slug)` on a folder is the same call; `pin_app` with `html` on a folder app's slug is refused, as is a folder deploy on a single-file app's slug): validates `app.json`, copies the folder to a release (2 MB per file, 32 MB, 500 files), copies the database first, starts the new server on a fresh port, and switches viewers only once it answers — a server that fails to start leaves the previous release running and the tool tells you why. `apps/<slug>` must be a real folder inside your workspace: one that is a link leading out of it is refused by deploy, check, preview, screenshot, export and import, and `purge_app` leaves it where it is. A NEW folder app counts against the same limit of 24 visible apps as a pin (`app limit reached` — unpin one first). A NEW app, a changed manifest, or `"deploy_requires_approval": true` makes the release wait for the user's approval on the card (the ack says `pending approval` — say so); nothing you can do speeds that up, but you need not wait to ship a fix: a deploy while one waits REPLACES the waiting release under the next number and the card asks again (a card still showing the replaced copy refuses the click with "The release changed — review it again" and redraws), so look at the pictures, fix, deploy again, and ask for one approval of the copy you actually want live. Before deploying, **`check_app(slug)`** validates the folder and smoke-starts the server on scratch data without touching the live database; **`preview_app(slug)`** starts your working copy for the owner and editors (`/apps/<id>?preview=1`, seeded from a copy of the live data only when the manifest is approved and the app's approval switch is off, else on empty data) while everyone else keeps the live release. A preview starts with no secret values, and its egress route, bindings and events answer 403: a server that needs a secret at start may fail as a preview and still run live. **`deploy_status`**, **`app_logs`**, **`restart_app`** and **`purge_app(slug, confirm=slug)`** (removes the files, the releases AND the database — only when the user asks for exactly that) complete the set (on a shared app, `app_logs`, `restart_app` and `purge_app` need an editor or up when a person drives the chat, like a deploy); the user's menu has Roll back (the previous release with its data as it was) and "Delete app and its data".
- **The platform checks your app before it ships, and shows you what it looks like.** Every `deploy_app` and `check_app` runs the static checks first — the things the sandbox WOULD refuse, named with file and line: a raw `fetch`/`WebSocket`/`XMLHttpRequest` (use `otodock.fetch` / `otodock.ws`), a `<form>` submit, `localStorage`, `window.open`/`alert`/`prompt`, a top-level navigation, a script or stylesheet from another host, a `<base>`, an absolute `/client/` asset path, a feed, action or platform method the page names but `app.json` does not declare (an `otodock.platform('viewer.me')` too), an own reconnect in `onclose`, a server that binds another port or imports from `node_modules`, a `DROP`/`RENAME` migration, a handler route the manifest does not declare (in an HTML page the script rules read only its `<script>` bodies and `on*` attributes, never its text). A problem REFUSES the step (nothing is copied, nothing goes live; fix and call again); a warning goes through with "Worth fixing". Then the page is RENDERED: the copy that would ship runs as a check instance on a copy of the live data (on empty data while its manifest waits for approval or the app's approval switch is on), the platform's own headless browser opens the real app page at phone (390), tablet (820) and desktop (1280) widths plus the other theme, and the pictures come back to you as images with the verdict — page errors, requests the policy blocked, a page that never became ready. A page that fails in the browser is refused before it goes live, exactly like a server that will not start. The render viewer is a synthetic member of the agent: personal feeds are empty and every button answers `not available in the rendered check` without a call (a server that forwards the render's claim to `platform/…` or a binding gets the same refusal), so the pictures show your empty and refused states — read them as such (a Monitors tile that says "all up" over no data is the classic miss). The render also MEASURES how far the page runs past each width's edge and says so ("runs 140 px past the edge at phone width"): a picture is only the visible slice, so a table or a long line that does not fit is invisible in it and a sideways scroll on every phone. Nothing may widen the page — wrap the text (`min-w-0`, `break-words`), or put the wide thing in its own `overflow-x-auto` box. **`screenshot_app(slug)`** runs that render alone — your working folder (on the same data rule) or `source: "live"` (the live release on a copy of the live data) — for a look after an edit without a deploy. On an install whose file-tools image predates the renderer, the acks say the rendered check was not available and the static checks alone decide. On a remote machine whose satellite is older than the platform, `screenshot_app` alone is refused (the tool says so): `deploy_app` and `check_app` still render and attach the pictures — read those; never conclude "no renderer here" from that refusal. Third-party APIs are busiest at the top of the hour: a handler on a `0 * * * *` cron that fetches one should retry once after a short wait, or run at a minute of its own (`7 * * * *`).
- **Talk to the app yourself** with the session token the sandbox already holds: `curl -s -X POST "$PROXY_URL/v1/apps/<app_id>/api/cards" -H "Authorization: Bearer $PROXY_API_KEY" -H 'content-type: application/json' -d '{"title":"Review the Q3 deck"}'` — the app id is in the `deploy_app` ack and `list_apps`. The server sees you as the agent (`X-OtoDock-Basis: agent`). Works for this agent's apps (its shared apps and the current user's personal ones) and for the exported methods of an app a share placed in this agent (`list_apps` shows it under "Placed here" with its id and methods, `describe_app(agent, slug)` names them with their floors): there the server sees basis `placement` and your role here as the share gives it, at the method's `/api/<method>` route, where a binding's call arrives too, and nothing else of that app answers your session (no other route, no push, state or platform method). Any other agent's app is out of your session's reach.
- **Handlers — the platform wakes your server** without a viewer and without you. Declare them in `app.json`: `"handlers": {"on_schedule": {"digest": {"cron": "0 7 * * 1-5"}}, "on_trigger": ["github"], "on_event": {"refresh": ["task_finished", "chat_created", "file_changed", "trigger_fired"]}}` (names `^[a-z][a-z0-9_-]{0,39}$`, at most 16; `notification_created` for a personal app only; also `turn_finished` when a chat turn of the agent ends and `check_verdict` when a check's verdict lands, and `check:<name>` to BE the handler section of the check of that name — woken once per evaluation with the changed set, it answers `{"pass": bool, "score": 0..1 | null, "findings": [{"location", "severity", "text"}], "summary"}` in the response body within its 60 s; the `checks` skill) and serve each as `POST /_handler/<name>` — the proxy is the only caller that can reach it. The request carries `X-OtoDock-Basis: platform`, `X-OtoDock-Delivery-Id`, a signed `X-OtoDock-Viewer` claim (`principal: "platform"`, `handler`, `delivery`) and a JSON body `{event, handler, delivery_id, attempt, payload}` (a payload over 256 KB arrives cut down to its small top-level fields, with `payload_truncated: true`, `payload_dropped` naming what went and `payload_bytes` the original size — fetch the rest by id); check `claim.handler === name && claim.delivery === header` if you verify the claim. Answer within 60 s: any 2xx means done, a 4xx means "never retry this", a 5xx or no answer is retried four more times over about eighteen minutes with the SAME `delivery_id` — keep handlers idempotent (store the id, skip a repeat). Every wake is a row the user can read (`deploy_status` prints the last ten with their verdicts), and a wake starts a stopped server. A schedule handler also appears on the agent's Tasks tab as "<title>: <handler>" (pause it there; its cron lives in `app.json`). To write to the platform from a handler, forward the claim you received exactly as you forward a viewer's: `POST $OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/platform/files.write` (a declared write prefix), `…/platform/notifications.create` (goes to every member of the agent, or the owner of a personal app — 30 an hour), or `…/actions/<button id>` to press one of your own `fire_task` / `mcp_tool` buttons unattended; all with `Authorization: Bearer $OTODOCK_APP_TOKEN` and the `X-OtoDock-Viewer` you got — the platform accepts them only while that delivery is in flight, so do the work BEFORE you answer. A trigger aimed at a handler is created with `create_trigger(app_slug=…, handler=…)` (triggers-mcp) — or ships with a community template's app, whose `blueprint.json` `triggers` (`[{slug, handler, description}]`, at most eight) the platform seeds one per copy at install — owned by each member for a `user-apps/` app, by the agent for a shared one; a handler that changes what the app may do waits for the user's approval like any manifest change (the card lists the wakes).
- **Steps — a handler that runs a script where you run, no model.** For work a server cannot do (git, the workspace, a tool on the machine) declare the handler's wake as above and add a `steps` block naming the script: `"handlers": {"on_schedule": {"docs-sync": {"cron": "20 * * * *"}}, "on_trigger": ["docs-push"], "on_event": {"docs-synced": ["step:docs-sync"]}}, "steps": {"docs-sync": {"run": "scripts/sync-docs.sh", "timeout": 600}, "docs-push": {"run": "scripts/sync-docs.sh"}}` (a handler name is unique across the kinds, so a schedule and a trigger are two handlers — two steps may share one script). `run` is a file under the app folder outside `client/`, `server/` and `data/` (256 KB at most); `timeout` is seconds, 60 by default, two hours at most. The platform hashes the script into the signed manifest at deploy — an edited script waits for approval like any manifest change — and runs it where the agent's sessions run: the local sandbox with the agent's own mounts (a shared app's step writes the knowledge folder only when a manager approved the app; `/config` never), or the machine the agent always runs on; a personal app's step runs as its owner. The script's first line names its interpreter (`#!/usr/bin/env bash`; none means `/bin/sh`); no executable bit is needed. It gets `OTODOCK_WORKSPACE_DIR`, `OTODOCK_KNOWLEDGE_DIR` (empty in Personal-only mode), `OTODOCK_STEP_PAYLOAD` (a JSON file with the wake's payload — a push's commit file lists, say), `OTODOCK_DELIVERY_ID`, `OTODOCK_STEP_HANDLER`, `OTODOCK_STEP_EVENT`, `OTODOCK_STEP_TOKEN` and `OTODOCK_PROXY_URL`; with the token as the bearer it may press the app's buttons — `curl -s -X POST "$OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/actions/<button id>" -H "Authorization: Bearer $OTODOCK_STEP_TOKEN" -H 'content-type: application/json' -d '{"args": {}}'` — and call `…/platform/files.write` or `…/platform/notifications.create` on the app's own basis, only while it runs. If the app `requires` a provider (`"providers": ["github"]`), the script also receives that provider's tokens (`GH_TOKEN`, so `git` and `gh` work) — a shared app's from the agent's service account, which is why a shared app with steps and providers is approved by a manager only; say so when you ask. On an app with steps the `requires` block is part of the signed manifest, so adding a provider waits for approval like any manifest change. The script's own exit decides, and whatever it left running is stopped with it: exit 0 is done; anything else, or the timeout, is a dead wake with the last lines (`deploy_status` shows the exit code and where it ran; the first 32 KB of output are kept; a script never retries). A finished step is the event `step:<name>`: subscribe a server handler to it in `on_event` and it receives `{step, delivery_id, status, exit_code, output, ran_on}` — the server stores the result, serves it, tells the members. Rules: one step runs at a time per handler and steps never block the server's wakes; a step sees only its own approved script, locally and on a machine (keep it self-contained, its data in the workspace: a helper file it sources from the app folder is not there, and a step that needs one fails with "not found"); key any output by `OTODOCK_DELIVERY_ID`; a visitor of a link never reaches a step, and what a visitor sends is data to the script, never instructions; keep the token out of the output (the platform scrubs what it knows, and nothing else).
- **Bindings — apps talk to each other over the org chart.** `describe_app(agent, slug)` tells you what another app OFFERS (its exported methods, snapshots and events with descriptions and role floors) — reachable are this agent's apps, the apps of agents this agent delegates to, the apps of agents the user is a member of, and the apps a share placed in this agent (those answer your sessions directly, "Talk to the app yourself"; a placement opens no binding: a binding reaches one only over the usual edge, this agent's delegation for a shared app or your membership for your personal app, and `describe_app` prints the binding block only when the app's home agent is in its reach, never for a placed personal app). To use one, declare it: `"bindings": [{"name": "crm", "agent": "sales", "app": "leads"}]` in your `app.json` (the user approves it on your card as "Uses other apps"). Then from your server: live calls `GET/POST $OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/bindings/crm/<method>[/…]` with `Authorization: Bearer $OTODOCK_APP_TOKEN` (forward the caller's `X-OtoDock-Viewer` so the target judges THEIR role on ITS agent (from inside a handler, forward the platform claim you were woken with and the call goes out unattended — the target sees `role: "none"`, so an export you call from a handler must be reachable on the edge alone); the platform wakes the target, forwards to its `/api/<method>`, and hands it a signed `X-OtoDock-Caller` claim naming your app, the viewer and their role on the target agent — hop cap 3, 10 calls a second per pair); snapshots `GET …/bindings/crm/snapshot/<name>` (served from a file the target wrote, without waking it — send `If-None-Match` with the `ETag` you got and a 304 means nothing changed); events: declare `"handlers": {"on_event": {"refresh": ["app:crm:lead-created"]}}` and your handler is woken when the target emits. To OFFER things yourself: `"exports": {"methods": {"summary": {"description": "the week in numbers", "min_role": "editor"}}, "snapshots": {"board": {"description": "the board as JSON"}}, "events": {"lead-created": {"description": "a lead landed"}}}` — serve a method at `/api/<name>` (one route, served once: a binding's call and a placed agent's session's call both arrive there, while your own page and your own sessions reach your server at the path after `/api`, as they send it) and treat a request with `X-OtoDock-Caller` (basis `binding`) as another app: read `role` from that claim (the caller's role on YOUR agent, `none` for a stranger reaching you over an edge) and answer accordingly; the same methods answer the sessions of every agent a share placed your app in (basis `placement`, "Who is calling": the claim's `role` is already capped by the share and `agent` is the calling agent), so check the role per verb, a GET may be open while a POST needs editor, and a method a placed agent's nightly task must call carries no `min_role` (a session with no person ranks below every floor) and judges the claim itself; write a snapshot to `$OTODOCK_DATA_DIR/exports/<name>.json` (write `<name>.json.tmp`, then rename) on change or on your own schedule, agent-level data only, never per-user rows; emit with `POST $OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/events` `{"name": "lead-created", "payload": {…}}` (≤ 32 KB, 60 a minute; only the agent's shared app emits, a personal copy of the same slug is refused). Rules: export a small read API and a snapshot, not raw tables; subscribe to events instead of polling, and if you must poll, minutes not seconds; never persist another app's data beyond a cache; an app with no `exports.methods` answers no brokered call and no placed agent's session.
- **Blueprints — an app travels as a folder.** `export_app(slug)` writes `apps/<slug>.otoapp` next to the folder: the files a release would take (never the database, never `node_modules`), the `app.json` with every `fire_task` button rewritten to `"task": "<button id>"`, and a `blueprint.json` naming those tasks by slug with their prompts. Send it with `send_file`, or drop it in another agent's workspace and `import_app(path)` it there: the tasks are created for that scope, the buttons re-pointed, the folder written, and release 1 WAITS FOR THE USER'S APPROVAL — an import is never pre-approved, say so; a bundle whose button names an MCP that agent lacks is refused with the name. A community agent template ships apps the same way — `apps/<slug>/` (one shared copy) and `user-apps/<slug>/` (one copy per member, each with its own database: seeded for the installer at install and for each later member when they are attached; a `blueprint.json` beside it names the buttons' tasks and, since 1.7, its `triggers` — `[{slug, handler, description}]` aimed at the manifest's `handlers.on_trigger` names — which the platform seeds one per copy, owned by that member (their own address `/v1/webhooks/user/<username>/<app>-<slug>`, fired with their API key from User Settings → Integrations; a shared app's is the agent's), so a personal app can wake on a webhook of its member's own; an app with handlers is each member's to approve, and its trigger waits with the copy); their `requires.mcps` join the template's requirements. Those seeds are the one exception to "never pre-approved": release 1 is approved when the person installing the agent consented on the install dialog (an admin's consent reaches every member's copy, a creator's the shared apps and their own; a personal copy whose manifest acts as the member — an `mcp_tool` or `fire_task` button, `files.write`, handlers, steps, inbound hooks, secrets — only ever by that member), else it lands on the card like any deploy, and an agent installing a template with `create_agent` consents to nothing. The seed is cold: no rendered check, and the server starts on the first open. A template copy its owner removed (their delete, your `unpin_app`) is never seeded again; `pin_app(slug)` brings it back from the template's copy (after a purge, without its data). You reach a member's copy as any personal app: `list_apps` → its id → its own `/api/…` routes with the session token — the current user's copy only.
- **Widgets for app pages.** `<script src="/ui-kit/otodock-widgets.js"></script>` then `otodockWidgets.mount(el, {widget: 'sessions', title: 'Chats'})` — `sessions`, `lanes` (grouped by project), `tasks`, `notifications`, `checks` (the agent's checks with their last verdicts, a pass / fail pill that opens the judged chat), `file` (`{path, label}`), `connect` (`{provider}`) — render the platform's standard shapes in house style from the catalog feeds, live, with clicks that open the chat (a notification's under its own agent), the run, the agent's scheduled tasks, the file or the connect row. Declare the feed a widget binds as a `data_feed` action (`sessions`, `tasks`, `notifications`, `checks`, `file_changes`) and `integrations.status` as a `platform` action for `connect`; an undeclared feed shows the refusal in place. Mount once the document has loaded (`document.addEventListener('DOMContentLoaded', …)`): in a single-file app the runtime script is appended at the end of the body, so an inline script that runs earlier finds no `otodock.feed` and the widget says it needs the app runtime. Use the kit shapes first, build custom views when the shape differs.
- **The setup page — an app that welcomes a new member.** An agent installed from a template may onboard each member (a `user-setup.md` guide the agent walks them through in chat). A personal app can BE that welcome: declare `{"id": "setup", "label": "Setup state", "type": "platform", "method": "setup.status"}` and `{"id": "done", "label": "Finish setup", "type": "platform", "method": "setup.complete"}` (and the `file_changes` feed), then on load call `otodock.platform('setup.status')` FIRST — it answers `{user: "pending"|"done"|"none", agent: "pending"|"done"|"none", can_complete_agent, display_name, description, mcps}` (`none` when the agent ships no such guide; greet by `display_name`, never "your assistant" when it is there; `mcps` grades every MCP your manifest names — list the ones your cards rely on under `"requires": {"mcps": ["schedules-mcp", …]}`, a block that grants a page nothing and sits outside the approval signature except on an app with steps — as `{enabled, connected, fix: "none"|"connect"|"admin", reason}`: grey a card whose `fix` is not `none`, print its `reason` under it as it is, and for `fix: "connect"` make that line a button calling `otodock.open({kind: 'user_settings', tab: 'integrations'})`; a card that names no MCP is never greyed) — and render the welcome (what the agent does, one card per capability, a "Set up" button) while `user` is `pending`, the dashboard otherwise; warm your server (weather, lists) in the background meanwhile, so a cold start never delays the first screen. The button calls `otodock.platform('setup.complete', {scope: 'user'})` (no `args_schema` needed; `scope` defaults to `user`), which removes the member's guide exactly as the agent's own `complete_setup` tool does; a setup completed in chat instead reaches the page as a `file_changes` delta naming `users/<u>/context/user-setup.md` — flip on it, never poll. Both methods answer a person's own page only (never a bearer, never the app identity, never a render); `scope: 'agent'` is for a manager completing the agent-wide guide. An `mcp_tool` button on `agent-config-mcp.complete_setup` does NOT work here (that MCP is excluded from task mode, which is how app buttons run: it pins and approves, then fails at the click). Templates ship such an app under `user-apps/<slug>/` (one copy per member; the `agent-creation` skill): keep its manifest free of anything that acts as the member (the list in the Blueprints bullet), so the installer's consent approves every copy and the welcome, not an approval card, is the first thing a member sees.
- **Nothing secret in `client/`**: its files are served by content address to anyone holding the app id and the release hash. And nothing secret in the folder at all: a key the app needs is a **declared secret** (below), never a value you write.
- **Secrets — declare the name, a person sets the value.** When the app needs a vendor key (Stripe, a mail service, an API), declare it in `app.json` and NEVER ask the user for the value in the chat, never write it into a file: `"egress": ["api.stripe.com"], "secrets": [{"name": "STRIPE_SECRET_KEY", "required": true, "description": "a restricted key", "sends_to": {"host": "api.stripe.com", "header": "Authorization", "prefix": "Bearer "}}, {"name": "STRIPE_WEBHOOK_SECRET", "required": true}, {"name": "SMTP_PASSWORD", "env": true}]`. Names are `UPPER_CASE` (at most 16, never a name the runtime owns: `OTODOCK_*` / `OTO_*` / `BUN_*` / `LD_*` / `DYLD_*` / `PYTHON*` / `PATH` / `HOME` / `TMPDIR` / `LANG` / `PORT` / `BASH_ENV` / `ENV` / `GCONV_PATH`); a `description` is ONE short sentence — what the key is and where the person finds it ("a restricted Stripe key, Developers → API keys") — the settings panel clamps a longer one to two lines. Three uses, said by the declaration: `sends_to` — the platform adds the value to the app's calls to that host and **the server never sees it**: call the vendor through the egress route instead of directly, `fetch(\`${process.env.OTODOCK_PROXY_URL}/v1/apps/${process.env.OTODOCK_APP_ID}/egress/api.stripe.com/v1/checkout/sessions\`, {method: "POST", headers: {Authorization: \`Bearer ${process.env.OTODOCK_APP_TOKEN}\`, "content-type": "application/x-www-form-urlencoded"}, body})` (the host must be in `egress`; https only; the host must answer with a public address, a private one is a 403 naming it; a redirect is refused, a 304 passes through; 1 MB, 30 s, twenty calls a second; while a changed manifest waits for approval the route answers every call with a 403, the live server's included, until it is approved) — prefer this for every key that rides in a header; `env: true` — the server reads `process.env.NAME` itself (only when a library truly needs the raw value; the card shows it in amber); neither — the platform alone uses it (an inbound hook's signing secret). After `deploy_app`, a required secret without a value parks the release: the ack says `waiting: STRIPE_SECRET_KEY is not set` — tell the user that a manager (the owner of a personal app) sets it from the app's menu → Settings (or the card's Set button), then approves; `deploy_status` shows each name as `set` / `NOT SET`. You never see a value, never set one, and the log scrubs any value a server prints. Set `"deploy_requires_approval": true` on an app that holds secrets, so every release of it is a person's click; a deploy may turn that switch on, and turning it off takes a person's approval of the release that says so (the ack says `pending approval` with the reason "turns off approval for every deploy": say so). Previews never receive the values: the live instance alone does. A key travels only to the declared `egress` hosts — ask the user for restricted keys (Stripe's restricted keys, scoped tokens).
- **Inbound hooks — a vendor's events wake a handler.** For Stripe, GitHub or any webhook that signs with a shared secret, declare a hook instead of a trigger: `"inbound": {"stripe": {"verify": "stripe", "secret": "STRIPE_WEBHOOK_SECRET", "handler": "payment"}}` (`verify`: `stripe`, `github`, `hmac_sha256` with `"header": "X-Signature"` and an optional `"prefix"` / `"id_header"`, or `bearer` with an optional `"id_header"`; `secret` a declared secret the platform alone uses; `handler` a name of its own, unique among the handlers, at most 8 hooks). The platform verifies every event with the stored secret over the raw bytes — the app never checks a signature — and wakes `POST /_handler/<handler>` with `X-OtoDock-Basis: inbound` and the body `{event: "inbound:<hook>", payload: {hook, verify, body, headers, event_id, received_at}}`: read `JSON.parse(payload.body)`, key your work by `delivery_id` or the vendor's id (a 5xx is retried with the same id), answer within the minute. The address the person pastes into the vendor is `<the install's URL>/v1/apps/<app id>/inbound/<hook>` — shown with a Copy button in the app's menu → Settings, together with the secret to set; it answers "not found" until the manifest is approved and the secret is set. **An inbound wake is server-only**: it may update the app's own database and push to open pages, and never `files.write`, `notifications.create`, a button or a chat (the platform refuses the wake's claim there). To tell the owner "a booking was paid", the server calls `notifications.create` on its OWN identity — `POST $OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/platform/notifications.create` with `Authorization: Bearer $OTODOCK_APP_TOKEN` and NO `X-OtoDock-Viewer` header (declare the method as a `platform` action; it reaches the owner of a personal app or every member of a shared one, thirty an hour, never a named person) — from a customer's request or a vendor's event alike; that is the one write the app identity alone may do.

## Sharing — humans share, you don't

There is no tool for it: a user shares an app from its menu (with a colleague at a role they pick; a team app also with another agent they edit, or an admin with a whole department, which places it in those agents' Apps panels; or by a password link for someone without an account) and a chat from its history row; the platform's SHARING.md is the reference. What it means for what you build:

- **Someone an app was shared with acts at the share's role** (viewer by default; a share to a whole agent or department places the app in those agents' panels at the role the sharer picked, capped by each member's own role there): floored buttons refuse anyone below the floor, and on a shared PERSONAL app every button runs with the OWNER's connected accounts and tools — say so in the page where it matters ("Sends from Anna's calendar"). A grantee who is not also a member of the agent reads none of its files (`files.*` answer `not available: …`) and learns from `integrations.status` whether an account is connected, never its email.
- **Agents the app is placed in call its exports.** A share to an agent or a department places the app in those agents, and every session of such an agent, with or without a person, calls the app's `exports.methods` (never its whole API) at the person's role there capped by the share; a session with no person acts at a word below every floor, so a method a nightly task calls carries no `min_role`. A person's own accepted share admits that person's own chats and user-scope tasks on the agent they placed it in, at the share's cap uncapped; never a Shared-only agent's chats, the agent's own tasks or other people's sessions. The call arrives with basis `placement` ("Who is calling"), reaches no other route, push, state or platform method, and its claim is never forwarded on. Placing an app asks for no new approval: the calls its editors approved answer from the first call.
- **Know your audience.** Declare `{"id":"who","label":"Who uses it","type":"platform","method":"app.audience"}` and your server reads it with its launch token (`POST $OTODOCK_PROXY_URL/v1/apps/$OTODOCK_APP_ID/platform/app.audience`, no viewer forwarded; the live server only): `{members: [{sub, username, display_name, role, via: "membership"}], placements: [{agent, agent_name, kind: agent|department, share_id, role_cap, department: {id, name}|null, members: [{sub, username, display_name, role}], via: "placement"}], grantees: [{sub, username, display_name, role, decision: pending|accepted, placed_agent, share_id, via: "share"}], truncated}` — the roles as the floors judge them, each list cut at 500, never an email, and the usernames and display names blank while an admin closed the user directory to members (match viewers by `sub`, which `viewer.me` reports). A page reads it only for an editor or manager of the app's OWN agent, the owner of a personal app, or an admin; this agent's own sessions with no person read it as the app does, those with a person at their row; the card says at least "editors and up". Use it to give each person their page instead of collecting usernames by hand; data an office ASSIGNS per person (a project, a role on it) lives in your own rows keyed by `sub`, filtered by the claim on every request.
- **A document per viewer** (single-file apps). Declare `viewer.data.read` and `viewer.data.write` as platform actions and a page keeps its viewer's own notes and settings: `otodock.platform('viewer.data.write', {patch: {theme: 'dark'}})` (or `{doc}` to replace) and `otodock.platform('viewer.data.read')` → `{doc, rev}` (`{}` and 0 before the first write). One object up to 64 KB per viewer, 1000 viewers per app, the viewer's own page writes it and nobody else reads it (not you, not a server, not another viewer); every viewer the app admits has one (members, people it is shared with, members of agents it is placed in); it lives beside the app's data (`<slug>.viewers/`, outside any server's mount), survives an unpin and a re-pin of the slug, counts in the agent's quota and goes with a purge. A folder app is refused the two: it keeps per-viewer rows in its database. The state document stays one for all.
- **Opened through a link — `otodock.sharedLink`.** A link viewer gets the page and the state document as they were when the link was opened: no live state or push, no feeds, no navigation, no platform methods, and buttons only when the link's owner turned them on. `otodock.sharedLink` is `null` until the host says otherwise; on a link it becomes `{actions: true|false}` right after your page's `load` (the `otodock:shared-link` event fires then, and never on the dashboard). A folder app's page keeps `otodock.fetch` and `otodock.ws` on a link (its own API and socket), plus the accounts and the bot check below. Hide what a link cannot do instead of leaving dead controls, and never call `otodock.platform` or `otodock.feed` from that branch:

```html
<script>
addEventListener('otodock:shared-link', function(e){
  document.querySelectorAll('[data-members-only]').forEach(function(n){ n.hidden = true; });
  if (!e.detail.actions) document.querySelectorAll('.btn').forEach(function(b){ b.hidden = true; });
});
</script>
```

- **Keep member-only data out of the state document.** A link serves the whole document to whoever holds the URL (and the password). Put in state what a stranger may see; fetch the rest through `mcp_tool` / `platform` actions, which a link never gets unless its buttons are on.
- **Accounts kept by the host — visitors of a link who register and log in.** A link is one identity to the platform, so an app with its own customers (a booking engine) keeps its own accounts in its SQLite: `Bun.password.hash` / `Bun.password.verify` for passwords, a `sessions` table of random tokens (`crypto.randomUUID()` or 32 random bytes) with an expiry. The sandbox cannot keep a login across a reload, so the host does: after a successful login or registration the page calls `await otodock.session.set(sessionToken)` (the host stores it in a cookie of the link's, through the platform; resolves `{token, status}` — `unavailable` on the dashboard, where the viewer already has an identity); on logout `await otodock.session.clear()`. From then on **every request carries the session inside the verified claim**: on the server, decode `X-OtoDock-Viewer` (`claim.external === true`, `claim.grant` the link, `claim.session` the token the page set) and look the session up in your table — never trust a body field or a header the page sets, and `viewer.me` is refused on a link, so `/me` is your own route. `otodock.session.token` holds the current token in the page (null until the host says; `otodock:session` fires on every change), so a page renders logged-in or anonymous from it. Declare what a link may do in `app.json`: `"external": {"links": ["checkout.stripe.com"], "challenge": ["/register"], "session_days": 30}` — `links` the sites the host may open for the visitor (`otodock.openExternal`, below), `challenge` your own API paths whose non-GET calls from a link need a bot check, `session_days` how long the login lives (1 to 365). **The bot check**: before a registration, `const t = await otodock.challenge()` (the host shows the check; resolves with a token, or `null` where there is none — the dashboard, an install without Turnstile — which means "no check here": call anyway), then `otodock.fetch('/register', {method: 'POST', headers: {'X-OtoDock-Challenge': t || ''}, body})`: the platform verifies the token once on a listed path and your server never sees it. A public link with sign-ups needs this; the platform also limits calls per visitor and per address. An app with accounts must be a FOLDER app (a single-file app on a link gets no claim).
- **Opening an external site — `otodock.openExternal(url)`.** The frame never navigates; the host opens a new tab for the visitor, and on a link ONLY to a host you declared under `external.links`: `const r = await otodock.openExternal(session.url)` resolves `{status: 'opened' | 'blocked' | 'denied', reason}` — call it from the click handler itself (a user gesture is required; three opens per ten seconds), and show the reason in place when it is not `opened`. On the dashboard an undeclared host asks the viewer once (the consent chip); a declared one opens at once. This is how a payment works: the server creates a Stripe Checkout Session through the egress route (above) with `success_url` and `cancel_url` set to the LINK's own address plus a query such as `?paid=<booking id>` — the page sends the link's path (`new URL(otodock.sharedLink.url).pathname`; `otodock.sharedLink` is `{actions, links, url, query}` on a link) and the server checks it looks like `/s/<token>` and prefixes the platform's own origin from the request's `X-Forwarded-Proto` / `X-Forwarded-Host` headers, so a visitor can never point the return at another site — hands the page the session's `url`, the page opens it with `otodock.openExternal`, and Stripe returns the visitor to the link in that tab — still signed in through the host's cookie — where your page reads `otodock.sharedLink.query` and shows the booking; the payment itself lands through the inbound hook, never from the page's word. Never build a payment form inside the page (Stripe's card element is a third-party frame, which the sandbox refuses). `templates/booking/` in this skill is the worked example.

## Scoped dashboards (`pin_app` with `scope`) — the Dock

Besides standing apps, `pin_app` can bind a dashboard to a **chat** or a **delegation project** — it then lives on that chat's **Dock** (the panel button by the composer) instead of the apps strip. (`scope` picks WHERE the pin lives — the standing strip vs a Dock; `visibility` picks WHO sees it — don't conflate them. Multi-user project dashboards want `visibility="agent"` so every participant sees the pin.)

- `scope="project"` — pin it from any chat of the project (ids resolve from YOUR session's chat, never passed). It renders beside the platform's live lane cards on the project view. **Default to NOT pinning one**: the platform's built-in delegation dock (orchestrator card + live lane cards) is the standard surface for every delegation, projects included. Reserve a pinned project dashboard for genuinely BIG projects — many lanes over hours/days where a plan overview with owners and progress bars adds something the lane cards don't. If you do pin one, keep it about the PLAN (the lane cards already show live per-worker state) and re-pin on every board change (the same Edit-the-file + slug-only re-pin loop).
- `scope="chat"` — one progress dashboard for THIS chat. Use it for plan-scale single-chat work (a dev plan being executed, a research program, a long migration): milestones done/remaining, current phase, key numbers. Update it at milestones, not every message.
- **One dashboard per chat/project — a scoped re-pin REPLACES it** (that's the point: you own the scope's dashboard; approval carries over when the actions manifest is unchanged; a re-pin by another user or agent starts a new row, so the old one's state document does not carry). The slug can stay the same across updates; `list_apps` shows scoped pins tagged `chat-scoped`/`project-scoped`.
- Scoped dashboards die with their scope (chat deleted / last project chat deleted) — right for progress views. Anything the user should keep across chats belongs in a standing app instead.
- Authoring rules are identical to standing apps (Tailwind + mobile-responsive REQUIRED, declared actions, live `mcp_tool` data panels).

## File pins (`pin_file`) — living documents on the Dock

`pin_file(path, scope="chat"|"project")` pins an **existing workspace text file** to the Dock as a read-only row: collapsed by default, expand → rich markdown render that **live-updates as you edit the file** — zero upkeep, no re-pin loop, no HTML. This is the RIGHT tool for living documents; **never build an app just to display a file**:

- Prime use: pin the **plan file** to the project Dock (`pin_file("projects/<id>/plan.md", scope="project")`) so the user reads the current plan next to the live lane cards. Specs, meeting notes, reports, a lane's findings file — same pattern.
- Path is workspace-relative and must exist (the Dock renders it, it can't create it). `.md` renders rich; other text types render as plain code. Up to 6 pins per chat/project; re-pin the same path to retitle; `unpin_file(path)` removes the row (the file stays).
- Division of labor on a project Dock: the **board file** block is automatic (lanes/decisions), file pins carry the DOCUMENTS, a pinned dashboard (rare, big projects only) carries interactive views.
- On a remote machine the platform mirror renders — your edits appear after the end-of-turn sync, not mid-turn.

**Platform live feeds (`otodock.feed`) — live STATE without re-pins.** Declare a feed in `actions` — `{"id":"lanes","label":"Live lane status","type":"data_feed","feed":"project_lanes"}` — and subscribe in-page; the host pushes an initial snapshot plus every change, each viewer seeing their own permission-filtered slice (read-only; covered by the same one-time approval as buttons):

```html
<script>
otodock.feed('project_lanes', function(rows, err){
  if (err) { document.getElementById('lanes').textContent = err; return; }
  // rows: [{id, title, agent, delegate_role, status: generating|awaiting_user|idle, updated_at}]
  renderLanes(rows);
});
</script>
```

The feeds (each viewer sees their own permission-filtered slice; the callback gets the FULL row list on subscribe and again after every change, so always re-render from `rows`, never append):

- `project_lanes` — rows above; flows only on a project dock.
- `active_chats` — `{id, agent, title, phase: streaming|warming|finished}`, the viewer's active chats across agents; works on any app.
- `sessions` — this agent's chats and delegation lanes the viewer may open: `{id, title, status: generating|awaiting_user|idle, project_id, delegate_role, source_type, updated_at}` (`source_type` is `chat` | `phone` | `task` — a task run's chat reports `task` whether the run was minted before or after the platform started writing the column) (a chat that was just created arrives with `created: true`).
- `tasks` — this agent's scheduled and trigger tasks the viewer may see: `{id, name, task_type, schedule, schedule_text, interval_seconds, run_at, user_tz, effective_tz, next_run_time, enabled, scope, last_run: {id, status, completed_at, error_message} | null}`. PRINT `schedule_text` ("Daily at 08:00 (Europe/Athens)", "Every 17 hours", "Once on Sun 20 Sep, 05:00", "On trigger") — it is the schedule in words in the task's own zone (`effective_tz`), named when the words carry a clock time; never humanize the raw cron yourself. `next_run_time` is an ISO instant to format in the viewer's clock (`""`/`null` when nothing is scheduled). A finished run updates the task's `last_run` and `next_run_time`.
- `notifications` — the viewer's own inbox across agents (the newest 50; a person's page only, never a bearer — your session token is refused): `{id, title, body, severity, source, delivered_at, read, agent_slug, chat_id, href}`.
- `trigger_fires` — this agent's triggers the viewer may see: `{id, name, last_fired_at, fired_count, last_error}`.
- `checks` — this agent's checks the viewer may see, each with its last verdict: `{id: "agent:<name>" | "user:<name>", name, owner, description, mandatory, applies, condition, rounds, sections, broken, updated_at, last_verdict: {id, status: pass|fail|error|skipped, pass, score, summary, round, chat_id, run_id, ran_on, created_at} | null}`; a landing verdict updates the check's `last_verdict`, a written check arrives as its row, a removed one with `removed: true`. `check_verdicts` is the verdict list itself (the `checks` skill).
- `file_changes` — workspace files that change while the app is open: `{id, rel_path, file_id, source, at}`. The list starts EMPTY and grows from the moment the page subscribed; `file_id` is set for Collabora saves and absent for disk writes; a write on a remote machine's sandbox shows up after the next sync.

A feed may carry `min_role` like a button. NOTE: the dock itself always renders the live lane cards at the top — a pinned project dashboard must NOT duplicate a plain lane list under them. Use `project_lanes` only when the dashboard presents lane state in a genuinely different shape (per-lane progress against the plan, phase grouping, burndown) — otherwise skip the feed and let the built-in cards do their job. Division of labor: the feed carries live STATE, the Edit + re-pin loop carries CONTENT changes (plan/board edits). Feeds are subscriptions, not buttons — `otodock.action()` on a feed id is refused.

**Live data panels — refresh WITHOUT an agent turn (the DEFAULT for data).** When a dashboard's numbers come from your MCP tools (monitor statuses, device states, metrics, task lists), do NOT wire "Refresh" to `send_prompt`/`fire_task` — declare the queries as `mcp_tool` actions and let the PAGE call them and render the results itself: on load (fresh data on every visit, automatically) and from the same Refresh button. Zero agent turns, zero tokens, seconds instead of a whole conversation. Reserve `fire_task`/`send_prompt` for refreshes that genuinely need YOU — reasoning over the data, web research, rebuilding the page layout.

- **NEVER bake tool values into the page as constants.** A `const data = [...]` filled with the numbers you just fetched is a snapshot — stale the moment it's pinned, and "Refresh via agent re-pin" burns a whole agent turn to update what one direct tool call returns in seconds. If a number came from an MCP tool, the page must FETCH it: fire the declared `mcp_tool` action on load and render from its `otodock:action-result`. Declaring the actions in the manifest is NOT enough — a page that never calls `otodock.action(...)` has dead actions and fake data.
- **Parsing tool output in the page**: you already called these tools in this conversation — you KNOW their exact output text. Write the page's parser against that real output (JSON.parse when it's JSON, line/regex extraction when it's prose). When parsing fails, render the raw result text in place — never keep showing the previous numbers as if they were fresh. CAVEAT for agents running on a remote machine: your session's MCPs run THERE while app buttons run the platform-side install — the same tool can return a slightly different shape (version drift). Write shape-TOLERANT parsers (e.g. accept a bare array AND a `{items:[…]}` wrapper) — the raw-text fallback then makes any residual mismatch diagnosable in one glance.
- A `send_prompt` "deep refresh"/"analyze" button may COEXIST with the live queries — but the default Refresh (and the on-load fill) must be the direct `mcp_tool` calls.

```html
<script>
function refresh(){
  otodock.action('monitors');   // mcp_tool → uptime summary
  otodock.action('metrics');    // mcp_tool → prometheus query
}
addEventListener('otodock:action-result', function(e){
  if (!e.detail.ok) return;     // keep the last good data on errors
  if (e.detail.id === 'monitors') renderMonitors(e.detail.result);
  if (e.detail.id === 'metrics') renderMetrics(e.detail.result);
});
addEventListener('load', refresh);   // auto-refresh on every open
</script>
```

  Rules: the frame itself has NO network access (an app with a server reaches its own API and nothing else — "Apps with a server" above) — `otodock.action` on a declared `mcp_tool` action IS the bridge to your MCPs, so never conclude a live data panel is impossible because the sandbox blocks networking. Auto-firing on load is allowed ONLY for `mcp_tool` (never auto-fire `send_prompt`/`fire_task` — those cost real agent turns and stay user-gesture-only); pin the LAST KNOWN data into the HTML so the page is never empty, and let the on-load results overwrite it; results are the tool's text output — parse it (ask the tool for JSON where it supports it). Rate limits key on the action PLUS its args: one parameterized action can serve a whole control panel (different widgets = different args = independent calls), while an identical repeat within ~1s is refused — fire each query once per refresh, not in loops.

Files in this skill

  • SKILL.md80.2 KB
  • templates/booking/app.json1 KB
  • templates/booking/client/index.html18 KB
  • templates/booking/server/index.ts24.8 KB
  • templates/kanban/app.json405 B
  • templates/kanban/client/index.html4.3 KB
  • templates/kanban/server/index.ts8.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.