Agents over MCP
The tool surface, the scopes, and the consent gate.
This is the whole surface an outside agent gets — no SDK, no build step, nothing flashed to the device. An agent that speaks MCP connects to the companion's local server and calls these tools by name. Manage a device, draw a screen on it and get the answer back, or put a real app on it: it is all here, and it is all live today. The model behind the surface is MCP.
The scope on every tool
Every tool names exactly one scope, and a call is refused unless the token you handed the agent carries that scope. Five scopes, coarse on purpose — you grant a whole verb, not a per-tool checklist:
control— see and manage devices, change settings, move around the screens, sleep and wake, draw a live screen, and ask a question that waits for a press. The widest set.apps— put apps on a device and run their lifecycle: install, remove, enable, and grant or revoke what they may touch.library— read the extension library: what is available and what each one is.library_admin— change it: install, configure, connect, and test extensions.urgent— carries no tool of its own. It unlocks the full-screen alert form ofnotify; without it, that alert downgrades to a quiet card. A scope with zero tools is a real state, not a rounding error.
Every tool, and its scope
- Tools
- 41
- Scopes
- 5
| Tool | Required scope | What it does |
|---|---|---|
| app_install | apps | Install an app onto a device. `source` is exactly one of: {catalog_id, version?} (a published catalog app), {file_path} (a local .zeph-app), or {library_id} (a compiled-in app). `dest` = 'flash' | 'sd' (catalog/file only). Hosted apps run in the companion runtime. |
| app_set_enabled | apps | Enable or disable an installed app on a device. |
| app_uninstall | apps | Uninstall an app from a device (reverses a hosted install). |
| consent_grant | apps | Grant declared-tool scopes to an integration/app (additive, revocable). |
| consent_revoke | apps | Revoke a granted scope (omit 'scope' to revoke all for the id). |
| register_app | apps | DEPRECATED alias for register_surface (kept one release) — same behavior. Prefer register_surface; this name will be removed. (A registered app never ran code — it is a host-tethered surface; see the zeph-device skill.) |
| register_surface | apps | Author a durable SURFACE — a host-tethered set of device screens the user can place and bind. NOT an app: no code runs on the device or companion, and it freezes to its last snapshot when you disconnect (for a zero-setup single panel, use `present` instead). The manifest declares identity (id, name, icon, byline, description) and exports: faces (designed zui screens), widgets (slot content), pages (swipe-up panes; ir=null means you push content via update_surface), intents (button actions the user can bind — you receive them via subscribe_device_input), glances (ready-made device screens the user can add in the companion's glance editor) and skills (agent skill packages — id, semver, SKILL.md markdown, optional prefab zui screens — shown in the companion's Skills tab with your surface's name and installable into agent clients; removed with the surface). The surface joins the companion library under "From your assistant". See the zeph-device skill for the manifest recipe. |
| remove_app | apps | DEPRECATED alias for remove_surface (kept one release) — same behavior. Prefer remove_surface; this name will be removed. |
| remove_surface | apps | Remove your registered surface: its glances, widget slots, pages and bindings are evicted from connected devices (same rules as uninstalling any app) and the surface leaves the companion library. |
| update_snapshots | apps | DEPRECATED alias for update_surface (kept one release) — same behavior. Prefer update_surface; this name will be removed. |
| update_surface | apps | Push live content for your registered surface's snapshot-backed exports: a map of snapshot ref ('app_id.export_id', must be your surface's prefix) to zui JSON tree. Content lands on connected devices immediately and re-pushes on reconnect; if your client disconnects, the last snapshots simply go stale on-device. |
| app_inspect | control | Inspect a local .zeph-app file: identity, requested caps, and the signature/provenance verdict. Read-only. |
| app_list | control | The companion's app-library view for a device (id, enabled, version). |
| catalog_refresh | control | Fetch + verify + stage the published apps catalog so catalog installs (app_install with a catalog_id) resolve. |
| consent_pending | control | The declared scopes an integration requests that are not yet granted. |
| device_navigate | control | Navigate the device with NO physical input (#554) — drive the home glance strip OR the open app's Screen stack, so you can move around the device UI yourself (e.g. open an app, push/pop its screens, switch glances) instead of asking a human to tap. Provide EXACTLY ONE of 'glance' or 'app_verb'. glance: 'next' | 'prev' | '#<index>' (0 = the Apps head) | a glance id — switches the shown home glance. app_verb: 'open' (enter/goto a Screen by 'app_ref', e.g. 'apple-music.now-playing'), 'push'/'replace' (also need 'app_ref'), 'pop'/'close' (go back; 'close' from the app's first screen exits the app). The device reports the resulting focused surface on its status channel — read get_device_state to confirm where it landed. |
| device_power_cycle | control | Hard power-CYCLE one device at the PMIC (#1317) — it turns off and comes back on its own (a SoC-independent restart, more thorough than a reboot). Non-destructive. A device whose firmware predates this returns an unsupported error. |
| device_power_off | control | Power one device fully OFF at the PMIC (#1317). The device turns off and STAYS off until someone presses its physical power button — it does NOT come back on its own. Non-destructive (keeps all apps, data, settings and pairing). A device whose firmware predates this returns an unsupported error. |
| device_reboot | control | Reboot one device (#844). Default: a normal application reboot — the device goes down and returns on its own, re-attaching through discovery. Set 'dfu' to true to enter ESP32-S3 ROM USB download mode (vendor ENTER_DL) for reflash instead; in DFU the device stays in ROM until it is reflashed (it will NOT return to the app on its own). Non-destructive — keeps all apps, data, settings and pairing. |
| device_sleep | control | Put one device's screen to sleep now (#952). Idempotent; refused (busy) while a firmware flash owns the screen. The device wakes on button, tap (if enabled), urgent notification, or device_wake. |
| device_wake | control | Wake one device's screen now (#952). Idempotent; consumes no input — pair with device_navigate to open a surface with the screen lit. |
| get_device_state | control | Full state of one device by serial: the device summary, its device-synced settings (brightness, device_name, mic_gain), whether it is connected right now, and 'focused_surface' — the surface the device is CURRENTLY showing (the active glance id, or an app-surface ref), reported on the device status channel; null when the device is offline. This is the field device_navigate's landing-check reads. |
| get_screenshot | control | Capture what a device is showing right now (#1372 render-back) and return it as a PNG image. Use it to SEE the result of your push_screen / present / notify — the device renders your vector IR to its own panel, and this is how you read that panel back (a ground-truth pixel grab, not a re-render of your tree). The frame is the device's native resolution + color depth. Also available as the resource 'zeph://device/{serial}/screen'. Offline devices error; capture is unsupported on very old firmware. |
| list_devices | control | List every Zeph device this companion knows — connected and offline — with serial, name, firmware, connection state, transports and battery. |
| notify | control | Post a notification on the device's six-rung ladder (no app/glance setup needed). Levels: badge (bell count), marquee (one live status line), notice (toast card: icon+title+text), urgent (fullscreen takeover; needs the 'urgent' consent scope, else it downgrades to notice), prompt (question + up to 3 options — the call WAITS and returns the chosen option), modal (multi-page zui body document, 'body' arg — the call WAITS for the terminal reply: {action, values, steps}). The device may downgrade: modal->prompt->notice->badge (e.g. invalid body, DND); the result reports the effective level. Replies/dismissals also stream on the intents notification channel keyed by the returned id. Re-posting with a live 'id' updates that notification in place (progress/monitor case — same id, no re-surface flash). |
| present | control | Show content on your assistant's OWN ambient surface — a zero-setup, companion-provisioned panel your client owns (NO register_app, no glance wiring). ONE call places + fills + (by default) focuses it; unlike push_screen you don't manage a scratch ref or place a glance yourself — present owns the placement you couldn't do. 'tree' is a zui JSON IR root node (the same vocabulary as push_screen). 'ttl_seconds' auto-clears the CONTENT to a subtle idle face afterwards — the surface glance stays (no surprise disappearance of the container). 'focus' (default true) brings the surface to the foreground and wakes the screen. Interactive by construction: buttons AND value controls (sliders/toggles/selects) on the surface stream back to you via subscribe_device_input. Returns { surface_ref, shown (did it land in the foreground), glance_idx }. Use present for transient content you want visible now; register_app for a standing app the user keeps and arranges themselves. |
| push_asset | control | Push a RASTER image (photo, generated art, a chart you rendered — anything the declarative zui IR can't draw) to the device's asset store, then reference it from a screen. Prefer push_screen's vector IR for UI (crisp at any size, tiny, theme-aware); reach for push_asset only when you genuinely need pixels. Provide EXACTLY ONE image source: 'image' (base64 of the encoded bytes — PNG / JPEG / WebP / GIF, a 'data:...;base64,' prefix is accepted) OR 'url' (an https:// image the companion fetches under an SSRF guard: https-only, public hosts only, no redirects, image content-type, 8 MB cap). 'ref' is 'app_id.asset_id' and, exactly like push_screen, must NOT belong to a live app (an owned ref is re-fed by that app and would overwrite your asset — such refs are REFUSED; use a scratch ref like 'scratch.bg'). 'codec' picks the on-device encoding: 'qoi' (default — lossless, compact), 'jpeg' (lossy, smallest on the wire), or 'raw' (RGB565 passthrough). The companion adapts quality to the live transport and stores the decoded image under 'ref'; render it by pushing a zui tree that references it — the asset ref rides the image node's `id` (NOT a `ref` field): {"k":"image","id":"scratch.bg"} inline, or as a full-bleed background. The asset persists until overwritten or the device reboots. Oversized images are rejected — target the device panel resolution. |
| push_screen | control | Push an ephemeral screen to the device: a declarative zui JSON tree (the pushed-UI IR — nodes like {"k":"screen","c":[{"k":"big","t":"Hi"}]}) rendered through the device's snapshot path. 'ref' is 'app_id.export_id' and must NOT belong to a live app — an owned export is re-fed by that app's snapshot pump, which would silently overwrite your push, so such refs are REFUSED. Use a scratch ref no app owns (e.g. 'scratch.main', placed on a glance), or register your own app (register_app) and feed it via update_snapshots. The content shows wherever that export is on a glance (its face/page/widget slot). The push REPLACES that ref's previous content and stays until the next push or device reboot. Dismiss semantics: pass 'ttl_seconds' and the companion auto-clears the surface (pushes a blank tree over the same ref) when it elapses; or push new/blank content yourself at any time — there is no navigation side effect either way. |
| retract | control | Clear your assistant's ambient surface (the one `present` fills) back to its idle face immediately — the surface glance stays, the content goes. Use it when you're done showing something before its ttl_seconds elapses. |
| subscribe_device_input | control | Subscribe to this device's interactive events — everything the user does on YOUR surfaces and apps. Each arrives as an MCP 'notifications/message' with logger 'zeph.input' and a TAGGED payload: {serial, type:'intent', id, phase} — a button intent, id 'app_id.intent_id', phase press/release/tap/double (press+release = hold); {serial, type:'value', node_id, value} — an id-bearing value control (slider/toggle/select) on your surface moved; {serial, type:'swbutton', surface_ref, element_id, hold} — a composed software-button tap/hold. So a surface you `present` is interactive by construction — buttons AND value controls stream back. Events are scoped to you: you only see interactions on surfaces/apps you own, never another agent's or a system glance. One subscription per session per device; survives reconnects; ends when your MCP session closes. |
| subscribe_intents | control | DEPRECATED alias for subscribe_device_input (kept one release) — same behavior: subscribes to your device interactive events (intents + value controls + software buttons) on logger 'zeph.input', scoped to surfaces/apps you own. Prefer subscribe_device_input; this name will be removed. |
| update_settings | control | Write device-synced settings on one device (the same validated path the companion Settings page uses). Returns a per-setting outcome: synced, offline, or a validation error. |
| ext_detail | library | Full record for one extension by id (kind, status, capabilities, config). |
| ext_list | library | List the extension library — providers, connectors, tools, apps and skills — with id, kind, status and enabled state. Optional 'kind' filters (provider|connector|tool|app|skill). |
| ext_configure | library_admin | Set non-secret config for an extension (validated; secret keys are REFUSED — those go through ext_connect into the keychain). Privileged. |
| ext_connect | library_admin | Connect a provider with an API key: verified live, then stored in the keychain (never in config, never on-device). Privileged. |
| ext_disable | library_admin | Disable an extension (kept configured, hidden from pickers). Privileged. |
| ext_enable | library_admin | Enable an extension (#509). Privileged. |
| ext_install | library_admin | Install a user-declared extension from a manifest.json object. Validated and refused on a builtin-id collision. Privileged (human-approval gated). |
| ext_remove | library_admin | Remove a user install (uninstall) or clear a builtin's credentials+config (disconnect). Privileged. |
| ext_test | library_admin | Re-verify an extension's stored credentials. Privileged. |
A token carries scopes; a tool call is refused unless the token carries the scope in its row. The pairing is read from the server’s own dispatch table.
A token you created is not yet a token you trust.
The first time a new token calls in, the companion asks you to approve it — and until you do, every call is refused, whatever scopes the token carries. That one-time approval is the gate the whole surface sits behind. Minting the token and approving the client is walked through in Connect Claude Code.