Build
Ships today

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 of notify; 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
61
Scopes
5
control41apps11urgent0library2library_admin7
ToolRequired scopeWhat it does
app_cancel_installappsCancel an in-flight install/transfer for an app on a device.
app_dev_installappsBuild a dev app from the repo (`just app-build`, streaming build progress) then install it onto a device — the same verified path a Library sideload uses. Dev-only: errors ('not a dev companion') on a shipped build. Installs to flash.
app_installappsInstall 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). The device part installs to flash. Hosted apps run in the companion runtime.
app_remove_from_libraryappsRemove an app's companion part from this Mac. By default (§3.8) this does NOT uninstall from the app's devices — a device may be paired to other companions; set `cascade_devices` to also uninstall from the connected devices.
app_set_enabledappsEnable or disable an installed app on a device.
app_uninstallappsUninstall an app from a device (reverses a hosted install). Set `wipe_data` to also drop the app's persisted data (default false).
consent_grantappsGrant declared-tool scopes to an integration/app (additive, revocable).
consent_revokeappsRevoke a granted scope (omit 'scope' to revoke all for the id).
register_surfaceappsAuthor 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_surfaceappsRemove 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_surfaceappsPush 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_detailcontrolOne app's detail on a device (faces/widgets/pages/intents). Read-only.
app_dev_subjectcontrolThe dev build-on-demand consent subject for an app (caps + provenance), read PRE-build. Errors ('not a dev companion') off a dev machine. Read-only.
app_inspectcontrolInspect a local .zeph-app file: identity, requested caps, and the signature/provenance verdict. Read-only.
app_librarycontrolThe full companion app-library view for a device: each app's id, installed flag, version, and confirm state. Richer than app_list. Read-only.
app_listcontrolThe companion's app-library view for a device (id, enabled, version).
app_statuscontrolThe companion-side runtime status for an app (null when it has no running companion host). Companion-scoped, not per-device. Read-only.
app_updatescontrolThe {app_id -> update_available} map for a device (catalog-aware). Read-only.
assistant_memory_deletecontrolDelete now from what the Zeph companion stores about its assistant: one exchange ('exchange': exchange_id, from assistant_memory_list or an assistant_turn result; it ends its conversation's replies in progress first), one conversation ('conversation': conversation_id; it ends the conversation's replies in progress first), one remembered passage ('passage': passage_id; its exchange stays in its conversation), every legacy passage that belongs to no exchange ('unattached', which needs confirm: "unattached"), everything ('everything', which needs confirm: "everything"; dictation and recordings stay), one dictation's words in run history ('dictation': run_id, from the list's dictation) or every dictation's ('all_dictation', which needs confirm: "all_dictation"). An exchange goes with its passages, annotations and refusal records, so nothing deleted can be recalled or listed. There is no undo. Returns what was removed, and any store that could not be updated.
assistant_memory_dont_bring_upcontrolSet (on: true, the default) or clear (on: false) "Don't bring this up again" on one exchange of the Zeph assistant (its exchange_id, from assistant_memory_list or an assistant_turn result). Set, the exchange stays in its conversation, but its remembered passage is deleted and no other chat recalls or learns from it. Cleared, it is remembered again with the Memory model, when it was said while remembering was on and remembering is on now. Returns what happened: marked, remembered, paused, nothingToRemember, noMemoryModel, gone, or failed with a reason.
assistant_memory_listcontrolList what the Zeph companion stores about its assistant (memory controls, #1975): whether it remembers across conversations or is paused, every conversation (title, whether it is the current one, its turns and passages), a page of turns newest first (what was said and answered, when, by which model, from which surface: typed, the Mac voice, or a board by its name) with the remembered passages each can come back by and the Memory model (embedding space) that holds each copy, passages with no stored turn, any run-history record that still holds an assistant run's words, the words each dictation keeps in run history ('dictation': one record per run, with its run_id for assistant_memory_delete), and the forgets begun in the app that can still be undone ('pending'; what they cover is in no other field). Every turn is an exchange with its id; every conversation has its id (and, for one stored before memory was one file, its legacyId). Pass 'conversation_id' for one conversation's turns and 'before' (the previous page's moreBefore) for older ones.
assistant_memory_set_pausedcontrolPause (paused: true) or resume remembering across conversations for the Zeph assistant. While paused, no new passage is embedded and nothing is recalled from other conversations; each conversation's own transcript still saves. Turns said while paused are never remembered later. Returns the state.
assistant_memory_statuscontrolThe health of the Zeph assistant's memory store: one state (ok, paused, opening, migrating, busy, erasing, reindexing with its progress, degraded when the Memory model failed, and the states that need attention), with its tone and the words Settings > Memory shows. Holds none of what was said.
assistant_reactcontrolToggle a reaction (an emoji) on a message of a Zeph assistant exchange, and get back the exchange's reactions after the change (rowId, actor, emoji). Name the message by its exchange's id and its row's id (an assistant_turn result carries both: exchangeId, and rows.userRowId or rows.answerRowId), and give the emoji to react with. Set 'on' false to remove the reaction. Reactions persist across reopen — this is the headless equivalent of clicking a reaction in the chat UI.
assistant_turncontrolDrive the Zeph companion's chat assistant headlessly: send one message and get the whole turn back at once — the final reply, any tool calls it made (with results), the model's reasoning, and token usage. Uses the user's CONNECTED model provider and the SAME per-thread + long-term memory the chat UI uses (so replies persist and can be recalled). Omit 'thread_id' to start a new thread, or pass one from a previous call to continue it. Set 'allow_tools' to let the assistant actually run its built-in tools (files/fetch/git/time); left false, those tools are offered but calls are refused (the display-only present_view card tool still runs; its card is saved with the thread). This calls the connected provider, which may consume its credits/quota. Errors with a 'no-provider' hint when no model provider is connected. The result's exchangeId names the exchange the turn was stored as, and its rows its messages' ids (rows.userRowId, and rows.answerRowId when an answer was kept); both are absent when nothing was kept. assistant_memory_delete deletes the exchange by scope 'exchange', and assistant_react reacts to a message by its exchangeId and row id.
catalog_detailcontrolOne catalog entry's detail (versions, caps, provenance). Read-only.
catalog_listcontrolList published catalog entries, optionally filtered to one kind (app/provider/skill/…). Read-only.
catalog_refreshcontrolFetch + verify + stage the published apps catalog so catalog installs (app_install with a catalog_id) resolve.
clip_deletecontrolDelete a stored clip from one device by id. Idempotent — deleting a clip that is already gone succeeds.
clip_downloadcontrolDownload a stored clip from one device to a file on this computer. The clip is a .zae Opus envelope; the transfer is transport-agnostic (works over BLE or USB). Returns the bytes written.
clip_listcontrolList the audio clips stored on one device (its recordings). Returns each clip's id and size in bytes. Use the id with clip_play, clip_download, or clip_delete.
clip_playcontrolPlay a stored clip aloud on one device's speaker, or stop playback. Use a clip id from clip_list.
clip_recordcontrolStart or stop recording audio on one device, straight to its storage (SD where present, else flash). This is the device's own microphone recorder — no app needed. Start with a name; stop finalizes the clip so it appears in clip_list.
clip_uploadcontrolUpload a .zae Opus clip file from this computer to one device as a new stored clip. Transport-agnostic.
consent_pendingcontrolThe declared scopes an integration requests that are not yet granted.
device_navigatecontrolNavigate 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_cyclecontrolHard 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_offcontrolPower 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_rebootcontrolReboot 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_sleepcontrolPut 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_wakecontrolWake one device's screen now (#952). Idempotent; consumes no input — pair with device_navigate to open a surface with the screen lit.
get_device_statecontrolFull 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_screenshotcontrolCapture 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_devicescontrolList every Zeph device this companion knows — connected and offline — with serial, name, firmware, connection state, transports and battery.
notifycontrolPost 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).
presentcontrolShow content on your assistant's OWN ambient surface — a zero-setup, companion-provisioned panel your client owns (NO register_surface, 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_surface for a standing surface the user keeps and arranges themselves.
push_assetcontrolPush 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_screencontrolPush 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 surface (register_surface) and feed it via update_surface. 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.
retractcontrolClear 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_inputcontrolSubscribe 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_intentscontrolDEPRECATED 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_settingscontrolWrite 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_detaillibraryFull record for one extension by id (kind, status, capabilities, config).
ext_listlibraryList 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_configurelibrary_adminSet non-secret config for an extension (validated; secret keys are REFUSED — those go through ext_connect into the keychain). Privileged.
ext_connectlibrary_adminConnect a provider with an API key: verified live, then stored in the keychain (never in config, never on-device). Privileged.
ext_disablelibrary_adminDisable an extension (kept configured, hidden from pickers). Privileged.
ext_enablelibrary_adminEnable an extension (#509). Privileged.
ext_installlibrary_adminInstall a user-declared extension from a manifest.json object. Validated and refused on a builtin-id collision. Privileged (human-approval gated).
ext_removelibrary_adminRemove a user install (uninstall) or clear a builtin's credentials+config (disconnect). Privileged.
ext_testlibrary_adminRe-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.

Next

On this page