What agents can do
The tool surface an approved agent gets, and the consent model you control around it.
An approved agent gets a scoped set of tools for the device: read its state, change its settings, draw on its screen, install an app. Which of those it can reach is a grant you make once and can pull back anytime.
An agent you have not approved is held. Its connection completes, but every tool call comes back as an honest awaiting approval until you decide. A token you minted is not yet a token you trust.
Approve an agent
You do all of this from the companion's MCP page.
Mint a token and give it grants
Create a token on the MCP page, tick the grants it should have, and copy it into your agent. A fresh token is inert until you approve the client behind it.
The first call raises a dialog
The first time that token calls in, the companion asks you to approve the client by name. Until you answer, its tool calls are refused with the awaiting approval message.
Approve or deny
Approve keeps the client and its tools start working, within the grants you gave. Deny deletes the credential outright, so a refused agent leaves nothing lingering half-trusted.
Revoke whenever you want
The MCP page lists every client. Remove one and its credential is gone; change its grants in place and a live session picks up the new set on its next call.
What each grant unlocks
A tool call is refused unless the client's token carries the grant that tool sits behind. Five grants cover the surface:
| Grant | What it lets an agent do |
|---|---|
| Device control | List and read your devices, change settings, move around screens, and put up a screen or a question. The widest part of the surface. |
| Installing apps | Put a published app on a device and manage it — install, enable, remove. |
| Urgent alerts | Post a full-screen alert that takes the screen and wakes the device. Without this grant, an urgent alert downgrades to a quiet card instead. |
| Reading the library | See the connectors and providers you have set up. |
| Managing the library | Install, configure, enable, and remove those connectors and providers. |
The full surface
Every tool the companion exposes, and the grant each one needs:
- 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.
How the gate actually behaves
A minted token is stored as a digest and starts unapproved, so a stolen token file gives nothing away and an unapproved client can hold a connection but run no tools. Approving flips the client to trusted; denying revokes it, because a denied credential should not survive as a half-trusted one.
Grants are read fresh on each tool call, so changing a client's grants takes effect on its very next call with no reconnect — and pulling every grant, or removing the client, cuts it off at once.