{
  "version": 1,
  "project": {
    "name": "lattices",
    "title": "Lattices",
    "description": "Windows, workspaces, terminal sessions, and voice input."
  },
  "generatedAt": "2026-10-28T16:13:29.025Z",
  "artifacts": {
    "llms": "/lattices/docs/llms.txt",
    "agents": "/lattices/docs/AGENTS.md",
    "fullManifest": "/lattices/docs/agent-docs.json",
    "allMarkdown": "/lattices/docs/llms-full.txt",
    "markdownBase": "/lattices/docs/markdown/"
  },
  "recommendedReadOrder": [
    "lattices:overview",
    "lattices:what-is-lattices",
    "lattices:getting-started",
    "lattices:quickstart",
    "lattices:install-linux",
    "lattices:concepts",
    "lattices:guide-sessions",
    "lattices:guide-tiling",
    "lattices:guide-search",
    "lattices:guide-layers",
    "lattices:guide-machines",
    "lattices:guide-agents",
    "lattices:app",
    "lattices:config",
    "lattices:layers",
    "lattices:workspace-map",
    "lattices:tiling-reference",
    "lattices:mouse-gestures",
    "lattices:ocr",
    "lattices:visit",
    "lattices:voice",
    "lattices:agents",
    "lattices:api",
    "lattices:remote-streaming",
    "lattices:mcp",
    "lattices:agent-layer-guide",
    "lattices:pi-lattices",
    "lattices:embedded-sdk",
    "lattices:terminal-kit",
    "lattices:security-identity"
  ],
  "docs": [
    {
      "id": "lattices:overview",
      "slug": "overview",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Overview",
      "description": "What Lattices owns in the product family, what ships with it, and where to start",
      "order": 0,
      "sourcePath": "docs/overview.md",
      "url": "/lattices/docs/overview",
      "rawUrl": "/lattices/docs/markdown/overview.md",
      "markdownUrl": "/lattices/docs/overview.md",
      "headings": [
        {
          "depth": 2,
          "text": "Lattices in the family",
          "anchor": "lattices-in-the-family"
        },
        {
          "depth": 2,
          "text": "What's included",
          "anchor": "what-s-included"
        },
        {
          "depth": 2,
          "text": "Requirements",
          "anchor": "requirements"
        },
        {
          "depth": 2,
          "text": "Where to go",
          "anchor": "where-to-go"
        }
      ],
      "tokensEstimate": 906,
      "frontmatter": {
        "title": "Overview",
        "description": "What Lattices owns in the product family, what ships with it, and where to start",
        "order": 0
      },
      "markdown": "---\ntitle: Overview\ndescription: What Lattices owns in the product family, what ships with it, and where to start\norder: 0\n---\n\nFind any project's windows. Put them in place, for you or your agent.\n\nLattices is a window manager that knows about projects. On macOS it is a\nmenu bar app, the `lats` CLI and a local agent API. On Linux it is a Bun\nhost, a Quickshell desktop app and the same CLI. People and agents share one\nmodel of the desktop: windows, spaces, workspace layers, tmux sessions and,\nwhen you turn it on, the text on screen.\n\nNew here? Read [What Lattices is](/lattices/docs/what-is-lattices), then\n[Getting started](/lattices/docs/getting-started).\n\n## Lattices in the family\n\nLattices, Action, Canvas and Voice share this website, repository and skills\ncatalog. Each product ships its own app and keeps its own lifecycle.\n\n| Product | Use it for | Docs |\n|---------|-----------|------|\n| **Lattices** | Window management, tiling, workspace layers, tmux sessions, screen text, voice commands | You are here |\n| **Action** | Native computer use: observe, resolve, act and record, including private agent layers | [Action docs](/action/docs/overview) |\n| **Canvas** | Spatial notes that float on the desktop | [Canvas docs](/canvas/docs/overview) |\n| **Voice** | Spoken output from agents and scripts | [Voice docs](/voice/docs/overview) |\n\nSome `lats` commands reach another product. For example, `lats action`\ninstalls and calls Action, and `lats voice say` forwards speech to the Voice\napp. [Concepts](/lattices/docs/concepts) shows each process, its local socket or network endpoint, and\nwhere every command goes. The [product map](/products) compares the products.\n\n## What's included\n\nScreen-text indexing is off by default. It starts only after you grant\nScreen Recording permission and turn on **Search & OCR** in Settings; see\n[Screen scanning](/lattices/docs/ocr) for the scan intervals.\n\n| Component | Platform | Description |\n|-----------|----------|-------------|\n| **CLI** | macOS, Linux | The `lats` command. Tile windows, manage sessions, scan screen text, control the workspace from your terminal |\n| **Menu bar app** | macOS | Command bar, window tiling, project discovery, screen text indexing, voice commands |\n| **Linux host and app** | Linux (Hyprland) | `lattices-host` serves the agent API; a Quickshell app and a tray menu sit on top of it |\n| **Agent API** | macOS, Linux | Authenticated WebSocket on `lattices.sock` in 0.15.1. RPC methods and real-time events; see [Concepts](/lattices/docs/concepts#private-ipc-and-the-gateway) |\n| **MCP server** | macOS | `lats mcp` serves window, browser and remote-host tools to agent harnesses. See [MCP](/lattices/docs/mcp) |\n| **Screen scanner** | macOS | When enabled, reads text from visible windows with the Accessibility API and Apple Vision OCR and indexes it with FTS5 |\n| **Node.js client** | macOS, Linux | Zero-dependency `daemonCall()` helper for scripting |\n| **Embedded SDK** | macOS | Swift package for embedding Lattices capabilities into host apps or product-owned helpers |\n\n## Requirements\n\nmacOS:\n\n- macOS 26.0+\n- Node.js 18+ or Bun for the CLI\n- tmux for persistent terminal sessions (`brew install tmux`), optional\n- Swift 6.2 / Xcode 26+, only to build the menu bar app from source\n\nLinux:\n\n- Hyprland with a systemd user session; Omarchy on Arch Linux is the\n  supported automatic setup\n- See [Install on Linux](/lattices/docs/install-linux) for the full list\n\n## Where to go\n\nStart here:\n\n- [What Lattices is](/lattices/docs/what-is-lattices): the three jobs, what you install, and the words you will see\n- [Quickstart](/lattices/docs/quickstart): install the app and the CLI\n- [Getting started](/lattices/docs/getting-started): tile, find, place, and start a session in ten minutes\n- [Install on Linux](/lattices/docs/install-linux): the Hyprland host, app and CLI\n\nGuides:\n\n- [Tiling and placement](/lattices/docs/guide-tiling)\n- [Finding windows](/lattices/docs/guide-search)\n- [Sessions](/lattices/docs/guide-sessions)\n- [Layers](/lattices/docs/guide-layers)\n- [Working across machines](/lattices/docs/guide-machines)\n- [Lattices for AI agents](/lattices/docs/guide-agents)\n\nReference:\n\n- [Concepts](/lattices/docs/concepts): product boundary, processes, sockets and glossary\n- [Configuration](/lattices/docs/config): `.lattices.json` format and CLI commands\n- [Menu bar app](/lattices/docs/app): every shortcut and setting\n- [Screen scanning](/lattices/docs/ocr): AX text extraction, Vision OCR and full-text search\n- [Voice](/lattices/docs/voice): voice commands and the `lats voice` reference\n- [Agent guide](/lattices/docs/agents): canonical action contracts for voice, CLI and daemon clients\n- [Agent API](/lattices/docs/api): RPC method reference for agents and scripts\n- [MCP](/lattices/docs/mcp): the `lats mcp` server and its toolsets\n- [Workspace map](/lattices/docs/workspace-map): read-only terminal/JSON desktop snapshots and visible-surface hygiene\n- [Embedded SDK](/lattices/docs/embedded-sdk): host-owned helper model for Scout, HudsonKit and other embeddings\n",
      "content": "Find any project's windows. Put them in place, for you or your agent.\n\nLattices is a window manager that knows about projects. On macOS it is a\nmenu bar app, the `lats` CLI and a local agent API. On Linux it is a Bun\nhost, a Quickshell desktop app and the same CLI. People and agents share one\nmodel of the desktop: windows, spaces, workspace layers, tmux sessions and,\nwhen you turn it on, the text on screen.\n\nNew here? Read [What Lattices is](/lattices/docs/what-is-lattices), then\n[Getting started](/lattices/docs/getting-started).\n\n## Lattices in the family\n\nLattices, Action, Canvas and Voice share this website, repository and skills\ncatalog. Each product ships its own app and keeps its own lifecycle.\n\n| Product | Use it for | Docs |\n|---------|-----------|------|\n| **Lattices** | Window management, tiling, workspace layers, tmux sessions, screen text, voice commands | You are here |\n| **Action** | Native computer use: observe, resolve, act and record, including private agent layers | [Action docs](/action/docs/overview) |\n| **Canvas** | Spatial notes that float on the desktop | [Canvas docs](/canvas/docs/overview) |\n| **Voice** | Spoken output from agents and scripts | [Voice docs](/voice/docs/overview) |\n\nSome `lats` commands reach another product. For example, `lats action`\ninstalls and calls Action, and `lats voice say` forwards speech to the Voice\napp. [Concepts](/lattices/docs/concepts) shows each process, its local socket or network endpoint, and\nwhere every command goes. The [product map](/products) compares the products.\n\n## What's included\n\nScreen-text indexing is off by default. It starts only after you grant\nScreen Recording permission and turn on **Search & OCR** in Settings; see\n[Screen scanning](/lattices/docs/ocr) for the scan intervals.\n\n| Component | Platform | Description |\n|-----------|----------|-------------|\n| **CLI** | macOS, Linux | The `lats` command. Tile windows, manage sessions, scan screen text, control the workspace from your terminal |\n| **Menu bar app** | macOS | Command bar, window tiling, project discovery, screen text indexing, voice commands |\n| **Linux host and app** | Linux (Hyprland) | `lattices-host` serves the agent API; a Quickshell app and a tray menu sit on top of it |\n| **Agent API** | macOS, Linux | Authenticated WebSocket on `lattices.sock` in 0.15.1. RPC methods and real-time events; see [Concepts](/lattices/docs/concepts#private-ipc-and-the-gateway) |\n| **MCP server** | macOS | `lats mcp` serves window, browser and remote-host tools to agent harnesses. See [MCP](/lattices/docs/mcp) |\n| **Screen scanner** | macOS | When enabled, reads text from visible windows with the Accessibility API and Apple Vision OCR and indexes it with FTS5 |\n| **Node.js client** | macOS, Linux | Zero-dependency `daemonCall()` helper for scripting |\n| **Embedded SDK** | macOS | Swift package for embedding Lattices capabilities into host apps or product-owned helpers |\n\n## Requirements\n\nmacOS:\n\n- macOS 26.0+\n- Node.js 18+ or Bun for the CLI\n- tmux for persistent terminal sessions (`brew install tmux`), optional\n- Swift 6.2 / Xcode 26+, only to build the menu bar app from source\n\nLinux:\n\n- Hyprland with a systemd user session; Omarchy on Arch Linux is the\n  supported automatic setup\n- See [Install on Linux](/lattices/docs/install-linux) for the full list\n\n## Where to go\n\nStart here:\n\n- [What Lattices is](/lattices/docs/what-is-lattices): the three jobs, what you install, and the words you will see\n- [Quickstart](/lattices/docs/quickstart): install the app and the CLI\n- [Getting started](/lattices/docs/getting-started): tile, find, place, and start a session in ten minutes\n- [Install on Linux](/lattices/docs/install-linux): the Hyprland host, app and CLI\n\nGuides:\n\n- [Tiling and placement](/lattices/docs/guide-tiling)\n- [Finding windows](/lattices/docs/guide-search)\n- [Sessions](/lattices/docs/guide-sessions)\n- [Layers](/lattices/docs/guide-layers)\n- [Working across machines](/lattices/docs/guide-machines)\n- [Lattices for AI agents](/lattices/docs/guide-agents)\n\nReference:\n\n- [Concepts](/lattices/docs/concepts): product boundary, processes, sockets and glossary\n- [Configuration](/lattices/docs/config): `.lattices.json` format and CLI commands\n- [Menu bar app](/lattices/docs/app): every shortcut and setting\n- [Screen scanning](/lattices/docs/ocr): AX text extraction, Vision OCR and full-text search\n- [Voice](/lattices/docs/voice): voice commands and the `lats voice` reference\n- [Agent guide](/lattices/docs/agents): canonical action contracts for voice, CLI and daemon clients\n- [Agent API](/lattices/docs/api): RPC method reference for agents and scripts\n- [MCP](/lattices/docs/mcp): the `lats mcp` server and its toolsets\n- [Workspace map](/lattices/docs/workspace-map): read-only terminal/JSON desktop snapshots and visible-surface hygiene\n- [Embedded SDK](/lattices/docs/embedded-sdk): host-owned helper model for Scout, HudsonKit and other embeddings"
    },
    {
      "id": "lattices:what-is-lattices",
      "slug": "what-is-lattices",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "What Lattices is",
      "description": "A plain-language introduction to Lattices for someone who has never used it",
      "order": 0.1,
      "sourcePath": "docs/what-is-lattices.md",
      "url": "/lattices/docs/what-is-lattices",
      "rawUrl": "/lattices/docs/markdown/what-is-lattices.md",
      "markdownUrl": "/lattices/docs/what-is-lattices.md",
      "headings": [
        {
          "depth": 2,
          "text": "Three jobs",
          "anchor": "three-jobs"
        },
        {
          "depth": 2,
          "text": "Who it is for",
          "anchor": "who-it-is-for"
        },
        {
          "depth": 2,
          "text": "What you install",
          "anchor": "what-you-install"
        },
        {
          "depth": 2,
          "text": "Words you will see",
          "anchor": "words-you-will-see"
        },
        {
          "depth": 2,
          "text": "Related products",
          "anchor": "related-products"
        },
        {
          "depth": 2,
          "text": "Platforms",
          "anchor": "platforms"
        },
        {
          "depth": 2,
          "text": "Next",
          "anchor": "next"
        }
      ],
      "tokensEstimate": 942,
      "frontmatter": {
        "title": "What Lattices is",
        "description": "A plain-language introduction to Lattices for someone who has never used it",
        "order": 0.1
      },
      "markdown": "---\ntitle: What Lattices is\ndescription: A plain-language introduction to Lattices for someone who has never used it\norder: 0.1\n---\n\nLattices is a workspace manager for macOS. It keeps a live model of your\nwindows, desktops and terminal sessions, and it lets you act on that model\nwith keyboard shortcuts, the `lats` command, or an API that AI agents can\ncall.\n\nThis page explains what Lattices does, what you install, and which parts are\noptional. [Getting started](/lattices/docs/getting-started) walks through the\nfirst ten minutes.\n\n## Three jobs\n\nLattices does three things. Each one works on its own.\n\n**Place windows.** Press Control+Option+Right and the front window fills the\nright half of the screen. Run `lats tile left` for the same result from a\nterminal. Agents call `windows.place` on the daemon. All three routes move\nthe same window through the same code. See\n[Tiling and placement](/lattices/docs/guide-tiling).\n\n**Keep project context.** A project can declare its terminal layout in a\n`.lattices.json` file, and `lats start` builds that layout as a tmux session.\nA workspace layer groups the windows of one piece of work and switches them\nin and out as a set. See [Sessions](/lattices/docs/guide-sessions) and\n[Layers](/lattices/docs/guide-layers).\n\n**Make the desktop searchable.** `lats search` finds windows by title, app,\nsession or the text on screen. `lats place` finds a window and tiles it in one\nstep. See [Finding windows](/lattices/docs/guide-search).\n\n## Who it is for\n\n- Developers who work on several projects at once and rebuild the same\n  window layout every day.\n- People who run AI agents such as Claude Code or Codex and want those\n  agents to see and arrange the desktop.\n- People with more than one Mac, or a Mac and a Linux box, who want one\n  keyboard to reach all of them.\n\n## What you install\n\n| Part | What it is | Required |\n|------|------------|----------|\n| Menu bar app | `Lattices.app`. Hosts the daemon, the shortcuts and the command bar. | Yes |\n| CLI | `lats`, installed from the `@lattices/lattices` npm package. Bun runs it. | For terminal and agent use |\n| tmux | Terminal multiplexer. `lats start` needs it. | Only for sessions |\n| Screen Recording permission | Lets the app read window text for search. | Only for screen-text search |\n| Gateway | A separate process for remote access over Tailscale. | Only for other machines |\n\nThe app runs without the CLI. The CLI runs most commands only while the app\nis running, because the app hosts the daemon. See\n[Getting started](/lattices/docs/getting-started) for the install steps.\n\n## Words you will see\n\n| Term | Meaning |\n|------|---------|\n| Window | One macOS window. Lattices identifies it by a window id, written `wid`. |\n| Placement | A named screen position such as `left`, `top-right` or `2x2:1,1`. |\n| Session | A tmux session that Lattices created for one project directory. |\n| Layer | A named set of windows that Lattices shows together and puts away together. |\n| Space | A macOS desktop. Lattices can switch Spaces to reach a window. |\n| Daemon | The API server inside the menu bar app. The CLI and agents talk to it. |\n| Host | Another machine that runs Lattices and accepts commands or visits. |\n| Visit | Lending this Mac's mouse and keyboard to a host by pushing the pointer past a screen edge. |\n\n[Concepts](/lattices/docs/concepts) has the full glossary and the process map.\n\n## Related products\n\nLattices shares a repository, a website and a skills catalog with three\nother products. Each one is a separate app with its own install and its own\ndocs. Lattices does not need any of them.\n\n| Product | What it adds | Reached from `lats` as |\n|---------|--------------|------------------------|\n| [Action](/action/docs/overview) | Native computer use for agents: observe, click, type, record | `lats action` |\n| [Canvas](/canvas/docs/overview) | Notes that float on the desktop | `lats canvas` |\n| [Speech](/speech/docs/overview) | Spoken output from agents and scripts | `lats voice say` |\n\n## Platforms\n\nLattices on macOS requires macOS 26 or later. Lattices on Linux runs on\nHyprland and installs from source; see\n[Install on Linux](/lattices/docs/install-linux). This guide and the other\nguides describe the macOS app unless they say otherwise.\n\n## Next\n\n- [Getting started](/lattices/docs/getting-started): install, launch, tile,\n  search and place in ten minutes.\n- [Lattices for AI agents](/lattices/docs/guide-agents): skills, MCP and\n  the daemon API.\n",
      "content": "Lattices is a workspace manager for macOS. It keeps a live model of your\nwindows, desktops and terminal sessions, and it lets you act on that model\nwith keyboard shortcuts, the `lats` command, or an API that AI agents can\ncall.\n\nThis page explains what Lattices does, what you install, and which parts are\noptional. [Getting started](/lattices/docs/getting-started) walks through the\nfirst ten minutes.\n\n## Three jobs\n\nLattices does three things. Each one works on its own.\n\n**Place windows.** Press Control+Option+Right and the front window fills the\nright half of the screen. Run `lats tile left` for the same result from a\nterminal. Agents call `windows.place` on the daemon. All three routes move\nthe same window through the same code. See\n[Tiling and placement](/lattices/docs/guide-tiling).\n\n**Keep project context.** A project can declare its terminal layout in a\n`.lattices.json` file, and `lats start` builds that layout as a tmux session.\nA workspace layer groups the windows of one piece of work and switches them\nin and out as a set. See [Sessions](/lattices/docs/guide-sessions) and\n[Layers](/lattices/docs/guide-layers).\n\n**Make the desktop searchable.** `lats search` finds windows by title, app,\nsession or the text on screen. `lats place` finds a window and tiles it in one\nstep. See [Finding windows](/lattices/docs/guide-search).\n\n## Who it is for\n\n- Developers who work on several projects at once and rebuild the same\n  window layout every day.\n- People who run AI agents such as Claude Code or Codex and want those\n  agents to see and arrange the desktop.\n- People with more than one Mac, or a Mac and a Linux box, who want one\n  keyboard to reach all of them.\n\n## What you install\n\n| Part | What it is | Required |\n|------|------------|----------|\n| Menu bar app | `Lattices.app`. Hosts the daemon, the shortcuts and the command bar. | Yes |\n| CLI | `lats`, installed from the `@lattices/lattices` npm package. Bun runs it. | For terminal and agent use |\n| tmux | Terminal multiplexer. `lats start` needs it. | Only for sessions |\n| Screen Recording permission | Lets the app read window text for search. | Only for screen-text search |\n| Gateway | A separate process for remote access over Tailscale. | Only for other machines |\n\nThe app runs without the CLI. The CLI runs most commands only while the app\nis running, because the app hosts the daemon. See\n[Getting started](/lattices/docs/getting-started) for the install steps.\n\n## Words you will see\n\n| Term | Meaning |\n|------|---------|\n| Window | One macOS window. Lattices identifies it by a window id, written `wid`. |\n| Placement | A named screen position such as `left`, `top-right` or `2x2:1,1`. |\n| Session | A tmux session that Lattices created for one project directory. |\n| Layer | A named set of windows that Lattices shows together and puts away together. |\n| Space | A macOS desktop. Lattices can switch Spaces to reach a window. |\n| Daemon | The API server inside the menu bar app. The CLI and agents talk to it. |\n| Host | Another machine that runs Lattices and accepts commands or visits. |\n| Visit | Lending this Mac's mouse and keyboard to a host by pushing the pointer past a screen edge. |\n\n[Concepts](/lattices/docs/concepts) has the full glossary and the process map.\n\n## Related products\n\nLattices shares a repository, a website and a skills catalog with three\nother products. Each one is a separate app with its own install and its own\ndocs. Lattices does not need any of them.\n\n| Product | What it adds | Reached from `lats` as |\n|---------|--------------|------------------------|\n| [Action](/action/docs/overview) | Native computer use for agents: observe, click, type, record | `lats action` |\n| [Canvas](/canvas/docs/overview) | Notes that float on the desktop | `lats canvas` |\n| [Speech](/speech/docs/overview) | Spoken output from agents and scripts | `lats voice say` |\n\n## Platforms\n\nLattices on macOS requires macOS 26 or later. Lattices on Linux runs on\nHyprland and installs from source; see\n[Install on Linux](/lattices/docs/install-linux). This guide and the other\nguides describe the macOS app unless they say otherwise.\n\n## Next\n\n- [Getting started](/lattices/docs/getting-started): install, launch, tile,\n  search and place in ten minutes.\n- [Lattices for AI agents](/lattices/docs/guide-agents): skills, MCP and\n  the daemon API."
    },
    {
      "id": "lattices:getting-started",
      "slug": "getting-started",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Getting started",
      "description": "Install Lattices, tile a window, find a window, and start a project session in your first ten minutes",
      "order": 0.4,
      "sourcePath": "docs/getting-started.md",
      "url": "/lattices/docs/getting-started",
      "rawUrl": "/lattices/docs/markdown/getting-started.md",
      "markdownUrl": "/lattices/docs/getting-started.md",
      "headings": [
        {
          "depth": 2,
          "text": "1. Install the app and the CLI",
          "anchor": "1-install-the-app-and-the-cli"
        },
        {
          "depth": 2,
          "text": "2. Launch the app",
          "anchor": "2-launch-the-app"
        },
        {
          "depth": 2,
          "text": "3. Tile a window with a key",
          "anchor": "3-tile-a-window-with-a-key"
        },
        {
          "depth": 2,
          "text": "4. Open the command bar",
          "anchor": "4-open-the-command-bar"
        },
        {
          "depth": 2,
          "text": "5. Find and place a window from the terminal",
          "anchor": "5-find-and-place-a-window-from-the-terminal"
        },
        {
          "depth": 2,
          "text": "6. Start a session for a project",
          "anchor": "6-start-a-session-for-a-project"
        },
        {
          "depth": 2,
          "text": "7. Turn on screen-text search (optional)",
          "anchor": "7-turn-on-screen-text-search-optional"
        },
        {
          "depth": 2,
          "text": "Where to go next",
          "anchor": "where-to-go-next"
        }
      ],
      "tokensEstimate": 1034,
      "frontmatter": {
        "title": "Getting started",
        "description": "Install Lattices, tile a window, find a window, and start a project session in your first ten minutes",
        "order": 0.4
      },
      "markdown": "---\ntitle: Getting started\ndescription: Install Lattices, tile a window, find a window, and start a project session in your first ten minutes\norder: 0.4\n---\n\nAfter this page you can tile a window with a key, find and place a window\nfrom a terminal, and start a terminal session for a project. Each step says\nwhat to expect and what to do if it does not happen.\n\nRequirements: a Mac on macOS 26 or later and [Bun](https://bun.sh). tmux is\nneeded only for step 6.\n\nThe examples use an invented project, `tideline`, checked out at\n`~/dev/tideline`. Substitute your own project.\n\n## 1. Install the app and the CLI\n\nInstall the app with Homebrew:\n\n```bash\nbrew tap arach/tap\nbrew trust arach/tap\nbrew install --cask arach/tap/lattices\n```\n\nOr download the disk image from the\n[latest release](https://github.com/lattices-dev/lattices/releases/latest)\nand drag `Lattices.app` to `/Applications`.\n\nInstall the CLI:\n\n```bash\nbun install -g @lattices/lattices\n```\n\n`npm install -g @lattices/lattices` also works. The CLI still needs Bun at\nruntime.\n\nCheck:\n\n```bash\nlats help\n```\n\nExpected: the command reference prints. If the shell reports `command not\nfound`, add Bun's global bin directory to `PATH` and open a new shell.\n\n## 2. Launch the app\n\n```bash\nlats app\n```\n\nExpected: a Lattices icon appears in the menu bar and macOS asks for\nAccessibility access. Grant it in System Settings > Privacy & Security >\nAccessibility. Lattices moves windows through the Accessibility API, so\nnothing tiles until this permission is on.\n\nCheck that the CLI can reach the app:\n\n```bash\nlats daemon status\n```\n\nExpected: a status line with the daemon's version. If the CLI prints `Daemon\nnot running`, the app is not running or has not finished starting. Run\n`lats app` again and retry.\n\nStart the app at login, if you want it:\n\n```bash\nlats app install\n```\n\n## 3. Tile a window with a key\n\n1. Click any window to bring it to the front.\n2. Press Control+Option+Right.\n\nExpected: the window fills the right half of its display, below the menu bar\nand above the Dock. Control+Option+Left, Up and Down place the other halves.\n\nIf nothing moves, open System Settings > Privacy & Security > Accessibility\nand confirm Lattices is listed and on. Then try again.\n\n## 4. Open the command bar\n\nPress Control+Option+Space.\n\nExpected: the command bar opens. Type part of a window title, an app name or\na project name. Press Return to go to the top result. Press Escape to close\nthe bar.\n\nThe command bar is the fastest way to reach a window on another Space or\ndisplay. It also lists projects, layers and app commands.\n\n## 5. Find and place a window from the terminal\n\nSearch for a window by any word in its title or app name:\n\n```bash\nlats search chrome\n```\n\nExpected: a ranked list of matching windows with their window ids. If the\noutput is `No results for \"chrome\"`, try a word from a window title you can\nsee.\n\nFind the top match and tile it in one step:\n\n```bash\nlats place chrome left\n```\n\nExpected: the best-matching Chrome window comes to the front and fills the\nleft half. Without a position, `lats place` uses `bottom-right`.\n\n`lats tile <position>` moves the front window without a search:\n\n```bash\nlats tile right\nlats tile top-left\nlats tile maximize\n```\n\n[Tiling and placement](/lattices/docs/guide-tiling) lists every position.\n\n## 6. Start a session for a project\n\nThis step needs tmux:\n\n```bash\nbrew install tmux\n```\n\nWrite a config for the project and start it:\n\n```bash\ncd ~/dev/tideline\nlats init\nlats start\n```\n\nExpected: `lats init` prints `Created .lattices.json` and the config it\nwrote. For a project with a `dev` script in `package.json`, the config has two\npanes: a shell on the left at 60% width and the dev server on the right.\n`lats start` attaches your terminal to a tmux session named\n`tideline-<hash>`, where `<hash>` comes from the directory path.\n\nDetach with Ctrl+B then D. Run `lats start` again from the same directory to\nreattach. The dev server keeps running in between.\n\nIf `lats init` prints `.lattices.json already exists.`, the project already\nhas a config. `lats start` uses it.\n\n[Sessions](/lattices/docs/guide-sessions) explains the config file and the\nsession lifecycle.\n\n## 7. Turn on screen-text search (optional)\n\nWith screen-text search on, `lats search` and the command bar also match\nwords that are visible inside windows, such as an error message in a\nterminal.\n\n1. Open System Settings > Privacy & Security > Screen Recording and turn\n   Lattices on.\n2. Open Lattices Settings from the menu bar and turn on **Search & OCR**.\n\nExpected: within about a minute, `lats scan` prints text from the visible\nwindows. [Finding windows](/lattices/docs/guide-search) covers the search\ntiers.\n\n## Where to go next\n\n- [Tiling and placement](/lattices/docs/guide-tiling)\n- [Finding windows](/lattices/docs/guide-search)\n- [Sessions](/lattices/docs/guide-sessions)\n- [Layers](/lattices/docs/guide-layers)\n- [Lattices for AI agents](/lattices/docs/guide-agents)\n- [Menu bar app](/lattices/docs/app) for every shortcut and setting\n",
      "content": "After this page you can tile a window with a key, find and place a window\nfrom a terminal, and start a terminal session for a project. Each step says\nwhat to expect and what to do if it does not happen.\n\nRequirements: a Mac on macOS 26 or later and [Bun](https://bun.sh). tmux is\nneeded only for step 6.\n\nThe examples use an invented project, `tideline`, checked out at\n`~/dev/tideline`. Substitute your own project.\n\n## 1. Install the app and the CLI\n\nInstall the app with Homebrew:\n\n```bash\nbrew tap arach/tap\nbrew trust arach/tap\nbrew install --cask arach/tap/lattices\n```\n\nOr download the disk image from the\n[latest release](https://github.com/lattices-dev/lattices/releases/latest)\nand drag `Lattices.app` to `/Applications`.\n\nInstall the CLI:\n\n```bash\nbun install -g @lattices/lattices\n```\n\n`npm install -g @lattices/lattices` also works. The CLI still needs Bun at\nruntime.\n\nCheck:\n\n```bash\nlats help\n```\n\nExpected: the command reference prints. If the shell reports `command not\nfound`, add Bun's global bin directory to `PATH` and open a new shell.\n\n## 2. Launch the app\n\n```bash\nlats app\n```\n\nExpected: a Lattices icon appears in the menu bar and macOS asks for\nAccessibility access. Grant it in System Settings > Privacy & Security >\nAccessibility. Lattices moves windows through the Accessibility API, so\nnothing tiles until this permission is on.\n\nCheck that the CLI can reach the app:\n\n```bash\nlats daemon status\n```\n\nExpected: a status line with the daemon's version. If the CLI prints `Daemon\nnot running`, the app is not running or has not finished starting. Run\n`lats app` again and retry.\n\nStart the app at login, if you want it:\n\n```bash\nlats app install\n```\n\n## 3. Tile a window with a key\n\n1. Click any window to bring it to the front.\n2. Press Control+Option+Right.\n\nExpected: the window fills the right half of its display, below the menu bar\nand above the Dock. Control+Option+Left, Up and Down place the other halves.\n\nIf nothing moves, open System Settings > Privacy & Security > Accessibility\nand confirm Lattices is listed and on. Then try again.\n\n## 4. Open the command bar\n\nPress Control+Option+Space.\n\nExpected: the command bar opens. Type part of a window title, an app name or\na project name. Press Return to go to the top result. Press Escape to close\nthe bar.\n\nThe command bar is the fastest way to reach a window on another Space or\ndisplay. It also lists projects, layers and app commands.\n\n## 5. Find and place a window from the terminal\n\nSearch for a window by any word in its title or app name:\n\n```bash\nlats search chrome\n```\n\nExpected: a ranked list of matching windows with their window ids. If the\noutput is `No results for \"chrome\"`, try a word from a window title you can\nsee.\n\nFind the top match and tile it in one step:\n\n```bash\nlats place chrome left\n```\n\nExpected: the best-matching Chrome window comes to the front and fills the\nleft half. Without a position, `lats place` uses `bottom-right`.\n\n`lats tile <position>` moves the front window without a search:\n\n```bash\nlats tile right\nlats tile top-left\nlats tile maximize\n```\n\n[Tiling and placement](/lattices/docs/guide-tiling) lists every position.\n\n## 6. Start a session for a project\n\nThis step needs tmux:\n\n```bash\nbrew install tmux\n```\n\nWrite a config for the project and start it:\n\n```bash\ncd ~/dev/tideline\nlats init\nlats start\n```\n\nExpected: `lats init` prints `Created .lattices.json` and the config it\nwrote. For a project with a `dev` script in `package.json`, the config has two\npanes: a shell on the left at 60% width and the dev server on the right.\n`lats start` attaches your terminal to a tmux session named\n`tideline-<hash>`, where `<hash>` comes from the directory path.\n\nDetach with Ctrl+B then D. Run `lats start` again from the same directory to\nreattach. The dev server keeps running in between.\n\nIf `lats init` prints `.lattices.json already exists.`, the project already\nhas a config. `lats start` uses it.\n\n[Sessions](/lattices/docs/guide-sessions) explains the config file and the\nsession lifecycle.\n\n## 7. Turn on screen-text search (optional)\n\nWith screen-text search on, `lats search` and the command bar also match\nwords that are visible inside windows, such as an error message in a\nterminal.\n\n1. Open System Settings > Privacy & Security > Screen Recording and turn\n   Lattices on.\n2. Open Lattices Settings from the menu bar and turn on **Search & OCR**.\n\nExpected: within about a minute, `lats scan` prints text from the visible\nwindows. [Finding windows](/lattices/docs/guide-search) covers the search\ntiers.\n\n## Where to go next\n\n- [Tiling and placement](/lattices/docs/guide-tiling)\n- [Finding windows](/lattices/docs/guide-search)\n- [Sessions](/lattices/docs/guide-sessions)\n- [Layers](/lattices/docs/guide-layers)\n- [Lattices for AI agents](/lattices/docs/guide-agents)\n- [Menu bar app](/lattices/docs/app) for every shortcut and setting"
    },
    {
      "id": "lattices:quickstart",
      "slug": "quickstart",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Quickstart",
      "description": "Install the Lattices app and the lats CLI on a Mac",
      "order": 0.5,
      "sourcePath": "docs/quickstart.md",
      "url": "/lattices/docs/quickstart",
      "rawUrl": "/lattices/docs/markdown/quickstart.md",
      "markdownUrl": "/lattices/docs/quickstart.md",
      "headings": [
        {
          "depth": 2,
          "text": "1. Install the app",
          "anchor": "1-install-the-app"
        },
        {
          "depth": 2,
          "text": "2. Install the CLI",
          "anchor": "2-install-the-cli"
        },
        {
          "depth": 3,
          "text": "One-command setup",
          "anchor": "one-command-setup"
        },
        {
          "depth": 2,
          "text": "3. Launch the app",
          "anchor": "3-launch-the-app"
        },
        {
          "depth": 2,
          "text": "Next",
          "anchor": "next"
        }
      ],
      "tokensEstimate": 380,
      "frontmatter": {
        "title": "Quickstart",
        "description": "Install the Lattices app and the lats CLI on a Mac",
        "order": 0.5
      },
      "markdown": "---\ntitle: Quickstart\ndescription: Install the Lattices app and the lats CLI on a Mac\norder: 0.5\n---\n\nThis page installs Lattices. After it, the menu bar app is running and\n`lats` works in your terminal. [Getting started](/docs/getting-started) then\nwalks through tiling, finding windows, and starting a project session.\n\nRequires macOS 26 or later on Apple Silicon. For Hyprland and Omarchy, use\n[Install on Linux](/docs/install-linux).\n\nThe current release is **0.15.2**. Install the app and CLI at the same version.\n\n## 1. Install the app\n\nWith Homebrew:\n\n```bash\nbrew tap arach/tap\nbrew trust arach/tap\nbrew install --cask arach/tap/lattices\n```\n\nOr download the [0.15.2 disk image](https://github.com/lattices-dev/lattices/releases/download/v0.15.2/Lattices.dmg)\nand drag `Lattices.app` to `/Applications`.\n\n## 2. Install the CLI\n\nThe CLI needs [Bun](https://bun.sh) at runtime, including when installed\nwith npm.\n\n```bash\nbun install -g @lattices/lattices\n```\n\nThis installs `lats`, with `lattices` as a long-form alias.\n`npm install -g @lattices/lattices` also works.\n\nCheck:\n\n```bash\nlats help\n```\n\nExpected: the command reference prints. If the shell reports `command not\nfound`, add Bun's global bin directory to `PATH` and open a new shell.\n\n### One-command setup\n\nOn a new Mac, the setup script installs Homebrew when needed, tmux, Bun,\nand the CLI. Review the [installer source](https://github.com/lattices-dev/lattices/blob/main/scripts/install.sh)\nbefore running it:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/lattices-dev/lattices/main/install.sh | bash\n```\n\n## 3. Launch the app\n\n```bash\nlats app\n```\n\nExpected: the Lattices icon appears in the menu bar and macOS asks for\nAccessibility access. Grant it; tiling and the command bar need it. When the\nCLI finds no installed app, `lats app` builds one from source if Swift is\navailable, or downloads a release.\n\nTo open Lattices at login:\n\n```bash\nlats app install\n```\n\n## Next\n\nContinue with [Getting started](/docs/getting-started): tile a window with\na key, open the command bar, find and place a window from the terminal, and\nstart a session for a project.\n",
      "content": "This page installs Lattices. After it, the menu bar app is running and\n`lats` works in your terminal. [Getting started](/docs/getting-started) then\nwalks through tiling, finding windows, and starting a project session.\n\nRequires macOS 26 or later on Apple Silicon. For Hyprland and Omarchy, use\n[Install on Linux](/docs/install-linux).\n\nThe current release is **0.15.2**. Install the app and CLI at the same version.\n\n## 1. Install the app\n\nWith Homebrew:\n\n```bash\nbrew tap arach/tap\nbrew trust arach/tap\nbrew install --cask arach/tap/lattices\n```\n\nOr download the [0.15.2 disk image](https://github.com/lattices-dev/lattices/releases/download/v0.15.2/Lattices.dmg)\nand drag `Lattices.app` to `/Applications`.\n\n## 2. Install the CLI\n\nThe CLI needs [Bun](https://bun.sh) at runtime, including when installed\nwith npm.\n\n```bash\nbun install -g @lattices/lattices\n```\n\nThis installs `lats`, with `lattices` as a long-form alias.\n`npm install -g @lattices/lattices` also works.\n\nCheck:\n\n```bash\nlats help\n```\n\nExpected: the command reference prints. If the shell reports `command not\nfound`, add Bun's global bin directory to `PATH` and open a new shell.\n\n### One-command setup\n\nOn a new Mac, the setup script installs Homebrew when needed, tmux, Bun,\nand the CLI. Review the [installer source](https://github.com/lattices-dev/lattices/blob/main/scripts/install.sh)\nbefore running it:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/lattices-dev/lattices/main/install.sh | bash\n```\n\n## 3. Launch the app\n\n```bash\nlats app\n```\n\nExpected: the Lattices icon appears in the menu bar and macOS asks for\nAccessibility access. Grant it; tiling and the command bar need it. When the\nCLI finds no installed app, `lats app` builds one from source if Swift is\navailable, or downloads a release.\n\nTo open Lattices at login:\n\n```bash\nlats app install\n```\n\n## Next\n\nContinue with [Getting started](/docs/getting-started): tile a window with\na key, open the command bar, find and place a window from the terminal, and\nstart a session for a project."
    },
    {
      "id": "lattices:install-linux",
      "slug": "install-linux",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Install on Linux",
      "description": "Install the Lattices app, CLI and desktop services on Hyprland, with optional Omarchy mouse gestures.",
      "order": 0.6,
      "sourcePath": "docs/install-linux.md",
      "url": "/lattices/docs/install-linux",
      "rawUrl": "/lattices/docs/markdown/install-linux.md",
      "markdownUrl": "/lattices/docs/install-linux.md",
      "headings": [
        {
          "depth": 2,
          "text": "Requirements",
          "anchor": "requirements"
        },
        {
          "depth": 2,
          "text": "Install",
          "anchor": "install"
        },
        {
          "depth": 3,
          "text": "Install from a checkout",
          "anchor": "install-from-a-checkout"
        },
        {
          "depth": 3,
          "text": "Inspect the plan",
          "anchor": "inspect-the-plan"
        },
        {
          "depth": 2,
          "text": "Optional Omarchy gestures",
          "anchor": "optional-omarchy-gestures"
        },
        {
          "depth": 2,
          "text": "Verify the installation",
          "anchor": "verify-the-installation"
        },
        {
          "depth": 2,
          "text": "Prepare an installation over SSH",
          "anchor": "prepare-an-installation-over-ssh"
        },
        {
          "depth": 2,
          "text": "Update",
          "anchor": "update"
        },
        {
          "depth": 2,
          "text": "Troubleshooting",
          "anchor": "troubleshooting"
        },
        {
          "depth": 2,
          "text": "Installed files",
          "anchor": "installed-files"
        }
      ],
      "tokensEstimate": 1497,
      "frontmatter": {
        "title": "Install on Linux",
        "description": "Install the Lattices app, CLI and desktop services on Hyprland, with optional Omarchy mouse gestures.",
        "order": 0.6
      },
      "markdown": "---\ntitle: Install on Linux\ndescription: Install the Lattices app, CLI and desktop services on Hyprland, with optional Omarchy mouse gestures.\norder: 0.6\n---\n\nLattices on Linux includes a desktop app, the `lats` CLI, a local host and a tray menu. The installer copies a runtime into your user account. You can remove the source checkout after installation.\n\n## Requirements\n\nUse a Linux machine with **Hyprland** and a systemd user session. **Omarchy on Arch Linux** is the supported automatic setup. The installer supports x86-64 and ARM64.\n\nRun the installer from a terminal inside your Hyprland desktop. Do not run the installer with `sudo`. It uses `sudo` only when Arch packages are missing.\n\nBun runs the app and CLI. Quickshell provides the desktop window and gesture trail. The installer also checks tmux, Python, grim, wtype, ffmpeg and tesseract. Other distributions must provide those commands before using `--no-deps`.\n\nTailscale is optional for local use. Set up Tailscale separately to pair with another machine. Version 0.15.1 uses authenticated private IPC locally and a separate gateway for remote access. See [Private IPC and the gateway](/lattices/docs/concepts#private-ipc-and-the-gateway) before enabling remote access.\n\n## Install\n\nRun:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/lattices-dev/lattices/main/install.sh | bash\n```\n\nThe installer installs missing Arch dependencies and Bun, copies the Linux runtime, adds `lats`, `lattices` and `lattices-host`, and creates the app launcher. It then enables the host and tray as systemd user services, runs `lats doctor`, and opens the app.\n\nThe host and tray start at login. The app window opens when you select Lattices in the Apps menu or run `lats app`.\n\nIf the installer cannot reach the desktop or the user service manager, it reports the failure. Use `--no-start` when preparing the installation outside the graphical session.\n\n### Install from a checkout\n\n```sh\ngit clone https://github.com/lattices-dev/lattices.git\ncd lattices\nbash install.sh\n```\n\nIf Bun and the system dependencies are already available, you can run `bun run linux:install` from the repository root.\n\n### Inspect the plan\n\n```sh\nbash install.sh --dry-run\n```\n\nDry-run mode does not download packages, write files or start services.\n\n## Optional Omarchy gestures\n\nThe core install leaves desktop bindings unchanged. Add Forward-button gestures with:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/lattices-dev/lattices/main/install.sh | bash -s -- --gestures\n```\n\nHold the Forward button, draw the gesture, then release:\n\n| Gesture | Action |\n| --- | --- |\n| Left | Previous existing space |\n| Right | Next existing space |\n| Down | Show or hide the Lattices overlay |\n| Down, then left | Send Enter to the window where the gesture started |\n| Up | Send the Fab recording toggle, when Fab is installed |\n\nA straight gesture needs 80 logical pixels. The Enter gesture needs about 80 pixels down followed by 80 pixels left. The pen trail shows the selected action. Incomplete Enter gestures cancel after the left turn is detected.\n\nThis option reserves the Forward button for gestures. Back and ordinary wheel-click keep their existing behavior.\n\nFor a mouse without a Forward button, enable wheel-click gestures explicitly:\n\n```sh\nbash install.sh --wheel-gestures\n```\n\n`--wheel-gestures` includes Forward-button support and reserves plain wheel-click for gestures. Middle-click paste and middle-click opening of links are unavailable while that binding is enabled.\n\n**Super+Ctrl+G** opens a 15-second button tester. It draws the trail without running actions. **Escape** exits the tester or cancels a held gesture.\n\nThe recording gesture uses a separately installed Fab CLI. This installer does not install Fab or Scout.\n\nThe installer backs up changed files, adds one managed include to `~/.config/hypr/bindings.lua`, and enables the `lattices.gestures` plugin in Omarchy's `shell.json`. Other bindings and plugins are preserved. A Hyprland configuration error restores the previous files.\n\n## Verify the installation\n\nRun:\n\n```sh\nlats help\nlats doctor\nsystemctl --user status lattices-host lattices-tray\nlats app\n```\n\nDoctor checks the host connection, window inventory, window placement, spaces, tmux and desktop events. A pass confirms the desktop API is available. It does not send input or move a window.\n\nIf `lats` is not on PATH, open a new terminal or run:\n\n```sh\nexport PATH=\"$HOME/.local/bin:$PATH\"\n```\n\nThe Apps launcher and services use absolute paths and can run before that PATH change.\n\n## Prepare an installation over SSH\n\nInstall files without starting the desktop components:\n\n```sh\nbash install.sh --no-start\n```\n\nWhen you return to a terminal inside Hyprland, activate and verify them with:\n\n```sh\n~/.local/bin/lats app install\n```\n\nThe installer keeps an installed gesture profile when you rerun it without gesture flags. To switch from wheel-click gestures to Forward-button gestures only, rerun with `--gestures`.\n\n## Update\n\nRun the one-command installer again. It downloads one source revision and copies a new runtime before switching the installed commands. Successful activation closes the previous managed app window, restarts the host and tray, and opens the updated app.\n\nOlder runtime directories remain available for recovery. They are stored under `~/.local/share/lattices/linux-runtime/releases/`.\n\nIf an older or manual installation owns a target file, the installer lists the conflict and stops before changing it. Review the listed paths. If migrating from a source checkout, close its Lattices app before replacing the launcher. To migrate the files with backups, use:\n\n```sh\nbash install.sh --replace-existing\n```\n\nThe same flag is available through `lats app install`. It also permits replacing a managed file that you edited. Backups are stored under `~/.local/state/lattices/install-backups/`.\n\n## Troubleshooting\n\n**The compositor socket is unavailable.** Run the installer from a terminal inside the current Hyprland session. A terminal opened before a compositor restart can retain an old `HYPRLAND_INSTANCE_SIGNATURE`. Use a new terminal. Use `--no-start` to install files without desktop access.\n\n**The host or tray did not start.** Inspect the service logs:\n\n```sh\njournalctl --user -u lattices-host -u lattices-tray -n 60\n```\n\nThe files remain installed if activation fails. Resolve the reported service or desktop error, then rerun `lats app install`.\n\n**Gesture configuration failed.** The installer restores the files it changed if Hyprland reports a configuration error. Check `hyprctl configerrors`. Core installation works without gesture flags.\n\n**A configuration file is a symlink.** Automatic gesture setup preserves symlinked configuration files and stops with the path to review. Manage the gesture include and plugin entry through your dotfiles, or install the core app without gestures.\n\n**Another Linux distribution is in use.** Install the requirements through that distribution's package manager. Then run `bash install.sh --no-deps` from a checkout. Hyprland and Quickshell remain required. Gesture setup requires Omarchy's Lua configuration and Hyprland 0.56 or later.\n\n## Installed files\n\n| Location | Contents |\n| --- | --- |\n| `~/.local/bin/lats`, `lattices`, `lattices-host` | Command launchers |\n| `~/.local/share/lattices/linux-runtime/` | Copied runtime releases, current link and installation receipt |\n| `~/.local/share/applications/org.lattices.Lattices.desktop` | App launcher |\n| `~/.config/systemd/user/lattices-host.service` | Local host service |\n| `~/.config/systemd/user/lattices-tray.service` | Tray service |\n| `~/.config/hypr/lattices-mouse.lua` | Optional gesture bindings |\n| `~/.config/omarchy/plugins/lattices.gestures/` | Optional pen trail |\n| `~/.local/state/lattices/install-backups/` | Replaced files and recovery copies |\n\nThe installer honors `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `XDG_STATE_HOME`. Command launchers stay in `~/.local/bin`. Saved layers, pairings and captures in `~/.lattices` are kept separately from installed code.\n",
      "content": "Lattices on Linux includes a desktop app, the `lats` CLI, a local host and a tray menu. The installer copies a runtime into your user account. You can remove the source checkout after installation.\n\n## Requirements\n\nUse a Linux machine with **Hyprland** and a systemd user session. **Omarchy on Arch Linux** is the supported automatic setup. The installer supports x86-64 and ARM64.\n\nRun the installer from a terminal inside your Hyprland desktop. Do not run the installer with `sudo`. It uses `sudo` only when Arch packages are missing.\n\nBun runs the app and CLI. Quickshell provides the desktop window and gesture trail. The installer also checks tmux, Python, grim, wtype, ffmpeg and tesseract. Other distributions must provide those commands before using `--no-deps`.\n\nTailscale is optional for local use. Set up Tailscale separately to pair with another machine. Version 0.15.1 uses authenticated private IPC locally and a separate gateway for remote access. See [Private IPC and the gateway](/lattices/docs/concepts#private-ipc-and-the-gateway) before enabling remote access.\n\n## Install\n\nRun:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/lattices-dev/lattices/main/install.sh | bash\n```\n\nThe installer installs missing Arch dependencies and Bun, copies the Linux runtime, adds `lats`, `lattices` and `lattices-host`, and creates the app launcher. It then enables the host and tray as systemd user services, runs `lats doctor`, and opens the app.\n\nThe host and tray start at login. The app window opens when you select Lattices in the Apps menu or run `lats app`.\n\nIf the installer cannot reach the desktop or the user service manager, it reports the failure. Use `--no-start` when preparing the installation outside the graphical session.\n\n### Install from a checkout\n\n```sh\ngit clone https://github.com/lattices-dev/lattices.git\ncd lattices\nbash install.sh\n```\n\nIf Bun and the system dependencies are already available, you can run `bun run linux:install` from the repository root.\n\n### Inspect the plan\n\n```sh\nbash install.sh --dry-run\n```\n\nDry-run mode does not download packages, write files or start services.\n\n## Optional Omarchy gestures\n\nThe core install leaves desktop bindings unchanged. Add Forward-button gestures with:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/lattices-dev/lattices/main/install.sh | bash -s -- --gestures\n```\n\nHold the Forward button, draw the gesture, then release:\n\n| Gesture | Action |\n| --- | --- |\n| Left | Previous existing space |\n| Right | Next existing space |\n| Down | Show or hide the Lattices overlay |\n| Down, then left | Send Enter to the window where the gesture started |\n| Up | Send the Fab recording toggle, when Fab is installed |\n\nA straight gesture needs 80 logical pixels. The Enter gesture needs about 80 pixels down followed by 80 pixels left. The pen trail shows the selected action. Incomplete Enter gestures cancel after the left turn is detected.\n\nThis option reserves the Forward button for gestures. Back and ordinary wheel-click keep their existing behavior.\n\nFor a mouse without a Forward button, enable wheel-click gestures explicitly:\n\n```sh\nbash install.sh --wheel-gestures\n```\n\n`--wheel-gestures` includes Forward-button support and reserves plain wheel-click for gestures. Middle-click paste and middle-click opening of links are unavailable while that binding is enabled.\n\n**Super+Ctrl+G** opens a 15-second button tester. It draws the trail without running actions. **Escape** exits the tester or cancels a held gesture.\n\nThe recording gesture uses a separately installed Fab CLI. This installer does not install Fab or Scout.\n\nThe installer backs up changed files, adds one managed include to `~/.config/hypr/bindings.lua`, and enables the `lattices.gestures` plugin in Omarchy's `shell.json`. Other bindings and plugins are preserved. A Hyprland configuration error restores the previous files.\n\n## Verify the installation\n\nRun:\n\n```sh\nlats help\nlats doctor\nsystemctl --user status lattices-host lattices-tray\nlats app\n```\n\nDoctor checks the host connection, window inventory, window placement, spaces, tmux and desktop events. A pass confirms the desktop API is available. It does not send input or move a window.\n\nIf `lats` is not on PATH, open a new terminal or run:\n\n```sh\nexport PATH=\"$HOME/.local/bin:$PATH\"\n```\n\nThe Apps launcher and services use absolute paths and can run before that PATH change.\n\n## Prepare an installation over SSH\n\nInstall files without starting the desktop components:\n\n```sh\nbash install.sh --no-start\n```\n\nWhen you return to a terminal inside Hyprland, activate and verify them with:\n\n```sh\n~/.local/bin/lats app install\n```\n\nThe installer keeps an installed gesture profile when you rerun it without gesture flags. To switch from wheel-click gestures to Forward-button gestures only, rerun with `--gestures`.\n\n## Update\n\nRun the one-command installer again. It downloads one source revision and copies a new runtime before switching the installed commands. Successful activation closes the previous managed app window, restarts the host and tray, and opens the updated app.\n\nOlder runtime directories remain available for recovery. They are stored under `~/.local/share/lattices/linux-runtime/releases/`.\n\nIf an older or manual installation owns a target file, the installer lists the conflict and stops before changing it. Review the listed paths. If migrating from a source checkout, close its Lattices app before replacing the launcher. To migrate the files with backups, use:\n\n```sh\nbash install.sh --replace-existing\n```\n\nThe same flag is available through `lats app install`. It also permits replacing a managed file that you edited. Backups are stored under `~/.local/state/lattices/install-backups/`.\n\n## Troubleshooting\n\n**The compositor socket is unavailable.** Run the installer from a terminal inside the current Hyprland session. A terminal opened before a compositor restart can retain an old `HYPRLAND_INSTANCE_SIGNATURE`. Use a new terminal. Use `--no-start` to install files without desktop access.\n\n**The host or tray did not start.** Inspect the service logs:\n\n```sh\njournalctl --user -u lattices-host -u lattices-tray -n 60\n```\n\nThe files remain installed if activation fails. Resolve the reported service or desktop error, then rerun `lats app install`.\n\n**Gesture configuration failed.** The installer restores the files it changed if Hyprland reports a configuration error. Check `hyprctl configerrors`. Core installation works without gesture flags.\n\n**A configuration file is a symlink.** Automatic gesture setup preserves symlinked configuration files and stops with the path to review. Manage the gesture include and plugin entry through your dotfiles, or install the core app without gestures.\n\n**Another Linux distribution is in use.** Install the requirements through that distribution's package manager. Then run `bash install.sh --no-deps` from a checkout. Hyprland and Quickshell remain required. Gesture setup requires Omarchy's Lua configuration and Hyprland 0.56 or later.\n\n## Installed files\n\n| Location | Contents |\n| --- | --- |\n| `~/.local/bin/lats`, `lattices`, `lattices-host` | Command launchers |\n| `~/.local/share/lattices/linux-runtime/` | Copied runtime releases, current link and installation receipt |\n| `~/.local/share/applications/org.lattices.Lattices.desktop` | App launcher |\n| `~/.config/systemd/user/lattices-host.service` | Local host service |\n| `~/.config/systemd/user/lattices-tray.service` | Tray service |\n| `~/.config/hypr/lattices-mouse.lua` | Optional gesture bindings |\n| `~/.config/omarchy/plugins/lattices.gestures/` | Optional pen trail |\n| `~/.local/state/lattices/install-backups/` | Replaced files and recovery copies |\n\nThe installer honors `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `XDG_STATE_HOME`. Command launchers stay in `~/.local/bin`. Saved layers, pairings and captures in `~/.lattices` are kept separately from installed code."
    },
    {
      "id": "lattices:concepts",
      "slug": "concepts",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Concepts",
      "description": "Product boundaries, private IPC and gateway processes, legacy ports, and command destinations",
      "order": 6,
      "sourcePath": "docs/concepts.md",
      "url": "/lattices/docs/concepts",
      "rawUrl": "/lattices/docs/markdown/concepts.md",
      "markdownUrl": "/lattices/docs/concepts.md",
      "headings": [
        {
          "depth": 2,
          "text": "Lattices and the product family",
          "anchor": "lattices-and-the-product-family"
        },
        {
          "depth": 3,
          "text": "Shared entry points",
          "anchor": "shared-entry-points"
        },
        {
          "depth": 2,
          "text": "Processes and ports",
          "anchor": "processes-and-ports"
        },
        {
          "depth": 3,
          "text": "macOS",
          "anchor": "macos"
        },
        {
          "depth": 3,
          "text": "Linux",
          "anchor": "linux"
        },
        {
          "depth": 3,
          "text": "Published-release ports",
          "anchor": "published-release-ports"
        },
        {
          "depth": 2,
          "text": "Private IPC and the gateway",
          "anchor": "private-ipc-and-the-gateway"
        },
        {
          "depth": 2,
          "text": "Where commands go",
          "anchor": "where-commands-go"
        },
        {
          "depth": 3,
          "text": "Current mismatches",
          "anchor": "current-mismatches"
        },
        {
          "depth": 2,
          "text": "Glossary",
          "anchor": "glossary"
        },
        {
          "depth": 2,
          "text": "How it works",
          "anchor": "how-it-works"
        },
        {
          "depth": 2,
          "text": "Core architecture",
          "anchor": "core-architecture"
        },
        {
          "depth": 3,
          "text": "Session naming",
          "anchor": "session-naming"
        },
        {
          "depth": 3,
          "text": "Window discovery via title tags",
          "anchor": "window-discovery-via-title-tags"
        },
        {
          "depth": 3,
          "text": "Space switching via SkyLight",
          "anchor": "space-switching-via-skylight"
        },
        {
          "depth": 3,
          "text": "Ensure/prefill restoration (requires tmux)",
          "anchor": "ensure-prefill-restoration-requires-tmux"
        },
        {
          "depth": 2,
          "text": "Agent control",
          "anchor": "agent-control"
        },
        {
          "depth": 2,
          "text": "Key shortcuts (inside tmux)",
          "anchor": "key-shortcuts-inside-tmux"
        }
      ],
      "tokensEstimate": 4686,
      "frontmatter": {
        "title": "Concepts",
        "description": "Product boundaries, private IPC and gateway processes, legacy ports, and command destinations",
        "order": 6
      },
      "markdown": "---\ntitle: Concepts\ndescription: Product boundaries, private IPC and gateway processes, legacy ports, and command destinations\norder: 6\n---\n\nThis page explains which product owns each capability, which processes run,\nand how clients connect. It also covers Lattices sessions, panes, layers and\nthe agent API.\n\n## Lattices and the product family\n\nThe products share a repository, website and skills catalog. Each\nproduct ships its own app and keeps its own lifecycle. Quitting one app does\nnot stop another.\n\n| Product | What it owns | Runtime | Docs |\n|---------|--------------|---------|------|\n| **Lattices** | Windows, tiling, spaces, workspace layers, tmux sessions, screen text, voice commands, the `lats` CLI and the `lats mcp` server | macOS menu bar app; on Linux, `lattices-host` plus a Quickshell app | [/lattices/docs/overview](/lattices/docs/overview) |\n| **Action** | Native observe, resolve, act and record; private agent layers; Action-owned browsers | `Action.app` and its local agent | [/action/docs/overview](/action/docs/overview) |\n| **Canvas** | Spatial notes: each note is a floating panel backed by a Markdown file | `Canvas.app` and the `canvas` CLI | [/canvas/docs/overview](/canvas/docs/overview) |\n| **Voice** | Spoken output: synthesis, the playback queue and the playback HUD | `Voice.app` (bundle ID `dev.lattices.Speech`) | [/voice/docs/overview](/voice/docs/overview) |\n\nCanvas 2.3.0 ships `Canvas.app` and the `canvas` CLI. `Blink.app` and `blink`\nremain compatibility names. Existing storage, bundle identity and `blink.sock`\nremain unchanged. The rename does not implement the older shared-canvas API proposal. See [Canvas installation](/canvas/docs/overview#install-and-launch).\n\nThe [product map](/products) shows the same family for people choosing what\nto install. The rest of this page describes processes, not products.\n\n### Shared entry points\n\n`lats` and `lats mcp` are Lattices interfaces. Some of their commands reach\nanother product:\n\n- `lats action ...` installs, launches and calls Action.\n- `lats voice say ...` asks the Lattices daemon to forward speech to Voice.app.\n- `lats mcp` serves the `browser_*` tools, which are Action Browser code that\n  ships inside the `lattices` npm package.\n\nThese commands are routing. They do not move ownership into Lattices. The\ndestination table below names the process that does the work.\n\n## Processes and ports\n\n**Lattices 0.15.1, Action 0.4.0 and Voice 0.3.0 use private IPC for local APIs.** Remote\naccess uses a separate, opt-in gateway on the verified Tailscale address,\nport 9399 by default. Starting a product does not start that gateway.\n\nInstall matching app and CLI versions. Earlier releases use the\n[legacy ports](#published-release-ports); their TCP clients must be upgraded.\n\n### macOS\n\n<MacRuntimeDiagram />\n\nThe default socket directory is `~/.lattices/run`, including Action's Chrome\nbridge. Canvas and Action's job service keep their sockets in their own\napplication-support directories.\n\n| Endpoint | Process | Owner | Replaces in earlier releases |\n|---------------------------|---------|-------|--------------------------------|\n| `lattices.sock` | `DaemonServer` in the menu bar app | Lattices | Local agent API on 9399 |\n| `voice.sock` | Embedded voice input runtime (Vox, through Hudson Voice) | Lattices | Voice runtime on 9398 |\n| `companion.sock` | Companion bridge in the menu bar app | Lattices | Companion listener on 5287 and its Bonjour advertisement |\n| `action.sock` | Action local agent | Action | Agent API on 4319 |\n| `action-chrome.sock` | Chrome extension bridge | Action | Chrome companion HTTP/WebSocket listener on 4321 |\n| `speech.sock` | Voice.app RPC server | Voice | Voice API on 9397 |\n| `blink.sock` in `CANVAS_HOME` (then `BLINK_HOME`, then the existing default) | Canvas.app | Canvas | Unchanged private socket |\n| `runtime/companion.sock` in Action's application support | Durable job service: jobs, artifacts and timeline | Action | Existing Unix socket; no fixed TCP port |\n| `gateway.sock` | Separate gateway process, local control | Lattices | New endpoint |\n| Tailscale address, TCP 9399 by default | Separate gateway process, remote RPC and companion relay | Lattices gateway | Direct remote connections to product runtimes |\n\nThe local APIs use filesystem permissions and authentication as described in\n[Private IPC and the gateway](#private-ipc-and-the-gateway).\nThe embedded voice runtime is available only in builds that include Hudson\nVoice. Voice input has no remote gateway route.\n\nThe Chrome extension uses native messaging over stdio. Its native host connects\nto the bridge through authenticated `action-chrome.sock`. Action's job service\nuses its own `runtime/companion.sock`. Lattices' device companion bridge uses\n`~/.lattices/run/companion.sock` for paired remote visits. These are three\nseparate services despite the shared companion name.\n\nPrivate IPC does not remove every network listener. Action-owned Chrome still\nuses an intentional loopback Chrome DevTools Protocol (CDP) port, 9335 for the\ndefault workspace. That browser endpoint is separate from Action's agent API\nand from the gateway.\n\n### Linux\n\n<LinuxRuntimeDiagram />\n\nIn 0.15.1, `lattices-host` is a Bun process with\n`lattices.sock` and `companion.sock` in `~/.lattices/run`. The Quickshell app's\nbackend and the tray use the local host. Remote requests enter through the\nseparate gateway. The Linux host has no TCP compatibility listener.\n\n`host.describe` reports the host's platform, capabilities and methods.\nAction.app, Canvas.app and Voice.app remain macOS apps. Action's RemoteEngine\ncan call the Linux host's `computer.*` methods through the gateway. See\n[Install on Linux](/lattices/docs/install-linux) and\n[Remote engine](/action/docs/remote-engine). The macOS rollout does not establish\nLinux or physical iOS runtime verification.\n\n### Published-release ports\n\nThis table applies to Lattices 0.14.1, Action 0.3.1 and Voice 0.2.0.\nIt is retained for upgrade reference. Binding and authorization are separate boundaries.\n\n| Port | Process | Binding and authorization in earlier releases |\n|------|---------|-------------------------------------------------|\n| 9399 | Lattices `DaemonServer` on macOS | Loopback; core methods have no token check. Speaking verbs require the Voice capability token, which `lats` sends |\n| 9398 | Embedded Lattices voice runtime | Wildcard listener; non-loopback peers rejected after accept. Requests require the token in `~/Library/Application Support/Lattices/Voice/hudson-voice-runtime.json` |\n| 9397 | Voice.app RPC server | Loopback; token from `~/Library/Application Support/Speech/RPC/capability` in `x-lattices-speech-token`; browser origins refused |\n| 4319 | Action local agent | Loopback; token required for attention-mode `drive.begin`, `workspace.dragFile` and `workspace.cancelOperation`. Other methods have no token check |\n| 4321 | Action Chrome companion bridge | HTTP/WebSocket listener; the Chrome extension connected directly to the bridge |\n| 5287 | Lattices companion bridge on macOS | All IPv4 interfaces with Bonjour discovery; human-approved pairing and signed requests |\n| 9399 and 5287 | Linux `lattices-host` and companion bridge | Local host calls use loopback; the host also listens on its tailnet address when available. Remote devices must pair |\n| none | Blink.app local API | `blink.sock` in the `BLINK_HOME` directory, mode 0600 |\n\nIn Lattices 0.15.1, port 9399 belongs to the opt-in gateway by default. In\nversions before 0.15.1, it belongs to the host or daemon. Matching the port\nnumber alone does not make clients compatible.\n\n## Private IPC and the gateway\n\n> **Versions:** Lattices 0.15.1, Action 0.4.0 and Voice 0.3.0 use private IPC.\n> The [process table](#processes-and-ports) lists the default\n> endpoints. Local clients connect directly to each product. Remote clients\n> use the gateway and its approved method grants.\n\n**Local services use private Unix sockets.** The main Lattices, Action and\nVoice APIs use `~/.lattices/run`; Canvas keeps its socket in its own data directory,\n`~/Library/Application Support/Blink` by default. A nonempty `CANVAS_HOME`\noverrides that path, followed by a nonempty `BLINK_HOME`.\nSocket directories are mode `0700`, and sockets are mode `0600`. Services in `~/.lattices/run`\nwrite a random capability token beside the socket, also mode `0600`.\n\nWebSocket services require the token in `x-lattices-token` during the upgrade.\nThe companion bridge requires that header on its HTTP requests too. These\nservices refuse browser `Origin` headers and unexpected `Host` headers. The\nmacOS core, companion and Voice servers also check the connected peer's user\nID. Action and the Bun services rely on the private directory and token.\nCanvas uses a peer-user-ID check and private filesystem permissions without a\ntoken. This is an account boundary, not isolation between programs running as\nthe same user.\n\nThe product clients find their sockets and credentials. For example, `lats`\nand `daemonCall()` find the Lattices socket and token. A raw WebSocket client\nmust read the service's token file, connect to its socket, and send\n`Host: localhost` with the token. The token is a local capability: the shared\nclient sends it over private IPC by default. An explicit TCP target, even a\nloopback address that could be an SSH tunnel, receives no local token unless\nloopback compatibility is explicitly enabled. For the CLI and Pi client, that\nrequires `LATTICES_ALLOW_LOOPBACK_TCP=1`. Non-loopback targets never receive\nlocal tokens. Canvas keeps its newline JSON protocol and needs no HTTP headers\nor token.\n\n`LATTICES_RUNTIME_DIR` moves the runtime directory for every participating\nprocess. Use it for isolated tests, not for normal operation.\n\n**Loopback TCP is a compatibility switch, not a default.** These macOS\nruntimes can open a loopback port when explicitly configured. The Linux host\nhas no TCP compatibility listener.\n\n| Product | Variable | Port | Token still required |\n|---------|----------|------|----------------------|\n| Lattices on macOS | `LATTICES_ALLOW_LOOPBACK_TCP=1` | 9399 | Yes |\n| Voice | `LATTICES_SPEECH_TCP=1` | 9397 | Yes |\n| Action | `ACTION_ALLOW_TCP=1` | 4319 | Yes |\n\n**Each product keeps its own lifecycle.** Launching or quitting Lattices,\nAction, Canvas or Voice.app does not start, stop or restart another product, and\nit does not start the gateway. The CLI connects to whichever runtimes are\nrunning and reports the others as unavailable.\n\n**Remote RPC and companion access use the gateway, and only on request.**\nThe gateway is a separate per-user process. Start it explicitly:\n\n```bash\nlats gateway start --tailnet   # background, until `lats gateway stop`\nlats gateway status\n```\n\nIt does not install a login item or boot service. It requires Tailscale and\nbinds only this machine's verified Tailscale address, on port 9399 by default.\nThere is no LAN, wildcard or unauthenticated fallback. It admits devices owned\nby this machine's Tailscale user unless you set `--allow-user` or\n`--allow-tag`.\n\nA remote client pairs, and a person on this machine approves it:\n\n```bash\n# On the remote machine\nlats --host HOST pair --read-only\n# Or request exactly these methods instead of the read-only scope:\nlats --host HOST pair --grant action:status --grant speech:speech.enqueue\n\n# On this machine: check the fingerprint and grants, then approve\nlats gateway clients\nlats gateway approve CLIENT_ID\nlats gateway revoke CLIENT_ID\n```\n\nEvery stored grant names one exact `product:method`. `--read-only` requests\nthe reviewed core Lattices read methods, which exclude screen contents. Add\n`--screen` for screenshots, streams, OCR and terminal contents, or `--shell`\nfor typing and launching commands. Plain `pair` covers read, screen and act;\n`--drive` adds desktop input and shell. Clients paired earlier keep their\nexact grants. Repeated `--grant` flags request\nexactly the listed methods, from any product, instead of a scope. You cannot\ncombine them with `--read-only` or `--drive`. A request for a new capability\nneeds another local approval. Re-pairing with the same client key adds approved\ngrants to the existing set. To reduce access, revoke the client and pair again.\nOnly methods on the gateway's reviewed list can be granted; the gateway rejects\nunknown methods and never lets a remote client choose a socket path or call\nlocal administration. If a product is not running, the gateway reports it as\nunavailable. It does not start it.\n\nThese stay local and the gateway does not forward them:\n\n- raw computer action events and Canvas event streams;\n- Action's connection-owned drive leases and workspace drag/cancel operations;\n- Voice's native playback reservation and event streams;\n- the embedded voice-input runtime and core `voice.*` forwarding;\n- the VNC `capture.live` listener. The gateway exposes no VNC.\n\nRemote visits use the authenticated companion relay through the gateway port.\nCompanion device trust and gateway client trust are separate stores, and\nexisting direct-host RPC pairings must pair again with the gateway. Companion\ndevice pairings remain in the companion's own store. The native Machines/Hosts\nview reuses the CLI gateway pairing; a companion pairing alone does not unlock\nremote host status or screens. Canvas's paired iOS note sync remains a separate\nMultipeer Connectivity service.\n\n## Where commands go\n\nThis table names the destination in the current versions. Earlier releases\nhave the same product ownership but use the\n[older transports](#published-release-ports).\n\n| You run | Process that receives it | Method family | Process that does the work |\n|---------|--------------------------|---------------|----------------------------|\n| `lats tile`, `lats place`, `lats search`, `lats layer`, `lats call ...` | Lattices daemon via `lattices.sock` | `windows.*`, `spaces.*`, `layers.*`, ... | Lattices |\n| `lats start`, `lats sync` | tmux, directly | n/a | tmux, configured by Lattices |\n| `lats computer ...` | Lattices daemon via `lattices.sock` | `computer.*` | Lattices |\n| `lats voice listen`, `stopListening`, `simulate`, `intents` | Lattices daemon via `lattices.sock` | `voice.*` | Lattices; transcription uses the embedded runtime via `voice.sock` |\n| `lats voice say`, `stop`, `pause`, `resume`, `skip`, `seek`, `list`, `select` | Lattices daemon via `lattices.sock` | `voice.*` | Voice.app via `speech.sock`, as `speech.*` |\n| `lats action call <method>` | Action agent via `action.sock` | Action methods | Action |\n| `lats action <verb>` (other verbs) | Action CLI from a runtime bundle or checkout | n/a | Action |\n| `lats mcp` tools `browser_*` | `lats mcp` stdio process | n/a | Action Browser code in the `lattices` package, using Action-owned Chrome profiles |\n| `lats mcp` tools `host_*` | Local Lattices runtime via `lattices.sock`, or the gateway for a configured remote host | daemon protocol | Lattices on the selected host |\n| `lats --host <name> ...` | Separate gateway on the verified Tailscale address, port 9399 by default | Signed requests with approved method grants | The selected product runtime via its private socket |\n| `canvas ...` (or `blink ...`) | Note files on disk; `canvas desk` also uses `blink.sock` | File operations and newline-delimited JSON | Canvas.app watches the same files |\n\n### Current mismatches\n\nSome interfaces cross or overlap the product boundaries.\nRead these before choosing an interface.\n\n1. **`lats computer` is Lattices, not Action.** The `computer.*` methods run in\n   the Lattices daemon (see the `computer.*` entries in the\n   [API reference](/lattices/docs/api)). Action is the separate computer-use\n   product with its own agent on `action.sock`.\n2. **Action reaches Linux through Lattices.** Action's `RemoteEngine` drives a\n   Linux machine by calling `computer.*` on that machine's `lattices-host`.\n   Current versions reach it through the gateway and approved grants.\n   The native Action app remains on macOS. See\n   [Remote engine](/action/docs/remote-engine).\n3. **`lats voice` has two destinations.** Listening verbs stay in Lattices.\n   Speaking verbs are a bridge: the daemon renames each call (for example\n   `voice.say` becomes `speech.enqueue`) and forwards it to Voice.app through\n   `speech.sock`. If Voice.app is not running, the speaking verbs fail. There is no fallback\n   inside Lattices. See [Voice](/lattices/docs/voice) and\n   [Voice](/voice/docs/overview).\n\nThe Hands-Off worker is a separate case. It speaks its replies through the\nembedded voice runtime on `voice.sock`. It does not use Voice.app.\n\n## Glossary\n\n| Term | Definition |\n|------|------------|\n| **Command Bar** | The menu bar app's primary interface (**Control+Option+Space** by default). Search and run actions for apps, windows, workspaces, and settings. |\n| **Workspace layer** | A named group of windows that you switch as one unit with **⌃⌥⇧** arrows or `lats layer`. See [Layers](/lattices/docs/layers). Not the same as an Action private layer. |\n| **Window Tiling**     | Snap terminal windows to preset screen positions (halves, quarters, thirds, maximize, center). Works from the CLI (`lats tile`) or the command bar. |\n| **Agent API** | Lattices RPC methods and events hosted by the menu bar app on macOS or `lattices-host` on Linux. Current versions use authenticated WebSocket over `lattices.sock`; versions before 0.15.1 use TCP 9399. See the [API reference](/lattices/docs/api). |\n| **Private IPC** | Local communication over a Unix socket owned by the current user. Product APIs authenticate local clients before accepting requests; the gateway is not involved. |\n| **Gateway** | Separate, opt-in process for remote access. It verifies Tailscale identity and signed requests, checks approved product/method grants, and connects to the product through private IPC. |\n| **Agent** | Any program that calls the agent API autonomously: an AI coding agent, a shell script, a CI pipeline, or a custom tool. |\n| **Session** | A persistent tmux workspace that lives in the background. Survives terminal crashes, disconnects, and closing your laptop. One session per project. Requires tmux. |\n| **Pane** | A single terminal view inside a session. A typical setup has two panes side by side: shell on the left, dev server on the right. Requires tmux. |\n| **Attach / Detach** | Attaching connects your terminal to an existing session. Detaching disconnects but keeps the session alive. Your shell and dev server keep running. Requires tmux. |\n| **Sync / Reconcile**     | `lats sync` brings a running session back in line with its declared config. It recreates missing panes, re-applies layout, restores labels, re-runs commands in idle panes. Requires tmux. |\n| **Ensure / Prefill** | Two modes for restoring exited commands on reattach. **Ensure** auto-reruns the command. **Prefill** types it but waits for you to press Enter. Set via `.lattices.json`. Requires tmux. |\n| **tmux** | Terminal multiplexer (optional). Provides persistent sessions, pane layouts, and command restoration. Install with `brew install tmux` on macOS if you want session management. |\n\n## How it works\n\n1. You create a `.lattices.json` file in your project root (or run `lats init`)\n2. The menu bar app discovers the project and adds it to the command bar\n3. You can tile windows, switch layers, search via OCR, and use the agent API\n4. With tmux installed, `lats start` also creates persistent terminal sessions:\n   - Each pane gets its command (shell, dev server, tests, etc.)\n   - The session persists in the background until you kill it\n   - You can attach/detach from any terminal at any time\n   - If `ensure` is enabled, exited commands auto-restart on reattach\n\n## Core architecture\n\n- The menu bar app is the core on macOS. It provides the command bar,\n  window tiling, OCR, project discovery and the agent API. Versions from 0.15.1 expose\n  that API through `lattices.sock`; versions before 0.15.1 use TCP 9399. The app\n  works with or without tmux.\n- The `lats` CLI calls the agent API for windows, layers, search and OCR. It\n  runs `tmux` commands directly for session management.\n- Local agents and scripts connect to the agent API over WebSocket, using\n  private IPC in current versions. Remote access goes through the\n  gateway and is limited to approved methods. The API lets agents discover\n  projects, tile windows, switch layers, read on-screen text and subscribe to real-time events. See the\n  [API reference](/lattices/docs/api).\n- The app and the CLI share one session naming convention, so they always\n  agree on which session belongs to which project.\n\n### Session naming\n\nA session name is `<basename>-<hash>`, where:\n\n- `basename` is the project directory name (non-alphanumeric chars replaced with `-`)\n- `hash` is the first 6 hex chars of the SHA-256 of the full absolute path\n\nThis guarantees unique session names even if two projects share the\nsame directory name (e.g. `~/work/app` and `~/personal/app`).\n\nBoth the CLI (Node.js `crypto.createHash`) and the app (Swift\n`CryptoKit.SHA256`) produce identical hashes.\n\n### Window discovery via title tags\n\nWhen lattices creates a tmux session, it sets the tmux option:\n\n```\nset-titles-string \"[lattices:<session-name>] #{pane_title}\"\n```\n\nThis embeds a `[lattices:name]` tag in the terminal window title. The\nmenu bar app uses this tag to find windows via three fallback paths:\n\n1. **CGWindowList** (needs Screen Recording permission): fastest,\n   reads window names from the window server\n2. **Accessibility API** (needs Accessibility permission): queries\n   AXUIElement window titles for the terminal app\n3. **AppleScript**: iterates Terminal.app or iTerm2 windows by name\n\n### Space switching via SkyLight\n\nThe menu bar app can switch macOS Spaces to reach a session's window.\nIt uses private SkyLight framework APIs loaded at runtime via `dlopen`:\n\n- `CGSMainConnectionID`: get the connection to the window server\n- `CGSGetActiveSpace`: current Space ID\n- `CGSCopyManagedDisplaySpaces`: enumerate all Spaces per display\n- `SLSCopySpacesForWindows`: find which Space a window is on\n- `SLSManagedDisplaySetCurrentSpace`: switch a display to a Space\n\nThis is the same approach used by [Loop](https://github.com/MrKai77/Loop)\nand other macOS window managers.\n\n### Ensure/prefill restoration (requires tmux)\n\nWhen you run `lats start` and a session already exists:\n\n1. lattices checks the `ensure` / `prefill` flag in `.lattices.json`\n2. For each pane, it queries `#{pane_current_command}` via tmux\n3. If the pane is running a shell (bash, zsh, fish, sh, dash), the original\n   command has exited. lattices then does one of these:\n   - **ensure**: sends the command + Enter (auto-restart)\n   - **prefill**: sends the command without Enter (manual restart)\n4. Then it attaches to the session as normal\n\n## Agent control\n\nThe agent API gives agents the same control as a human using the\nmenu bar app. An agent can list projects and windows, launch sessions,\ntile windows to screen positions, subscribe to real-time events\n(`windows.changed`, `tmux.changed`, `layer.switched`), and sync\nsessions back to their declared config.\n\nA typical orchestrator sets up a multi-project workspace in a few\n`daemonCall()` invocations. See the [Agent API reference](/lattices/docs/api)\nfor the full method list and code examples.\n\n## Key shortcuts (inside tmux)\n\nThese work when you're inside a tmux session:\n\n| Shortcut       | Action                |\n|----------------|-----------------------|\n| Ctrl+B  D      | Detach from session   |\n| Ctrl+B  X      | Kill current pane     |\n| Ctrl+B  Left   | Move to left pane     |\n| Ctrl+B  Right  | Move to right pane    |\n| Ctrl+B  Up     | Move to pane above    |\n| Ctrl+B  Down   | Move to pane below    |\n| Ctrl+B  Z      | Zoom pane (toggle)    |\n| Ctrl+B  [      | Scroll mode (q exits) |\n\nThe prefix `Ctrl+B` means: hold Control, press B, release both,\nthen press the next key.\n",
      "content": "This page explains which product owns each capability, which processes run,\nand how clients connect. It also covers Lattices sessions, panes, layers and\nthe agent API.\n\n## Lattices and the product family\n\nThe products share a repository, website and skills catalog. Each\nproduct ships its own app and keeps its own lifecycle. Quitting one app does\nnot stop another.\n\n| Product | What it owns | Runtime | Docs |\n|---------|--------------|---------|------|\n| **Lattices** | Windows, tiling, spaces, workspace layers, tmux sessions, screen text, voice commands, the `lats` CLI and the `lats mcp` server | macOS menu bar app; on Linux, `lattices-host` plus a Quickshell app | [/lattices/docs/overview](/lattices/docs/overview) |\n| **Action** | Native observe, resolve, act and record; private agent layers; Action-owned browsers | `Action.app` and its local agent | [/action/docs/overview](/action/docs/overview) |\n| **Canvas** | Spatial notes: each note is a floating panel backed by a Markdown file | `Canvas.app` and the `canvas` CLI | [/canvas/docs/overview](/canvas/docs/overview) |\n| **Voice** | Spoken output: synthesis, the playback queue and the playback HUD | `Voice.app` (bundle ID `dev.lattices.Speech`) | [/voice/docs/overview](/voice/docs/overview) |\n\nCanvas 2.3.0 ships `Canvas.app` and the `canvas` CLI. `Blink.app` and `blink`\nremain compatibility names. Existing storage, bundle identity and `blink.sock`\nremain unchanged. The rename does not implement the older shared-canvas API proposal. See [Canvas installation](/canvas/docs/overview#install-and-launch).\n\nThe [product map](/products) shows the same family for people choosing what\nto install. The rest of this page describes processes, not products.\n\n### Shared entry points\n\n`lats` and `lats mcp` are Lattices interfaces. Some of their commands reach\nanother product:\n\n- `lats action ...` installs, launches and calls Action.\n- `lats voice say ...` asks the Lattices daemon to forward speech to Voice.app.\n- `lats mcp` serves the `browser_*` tools, which are Action Browser code that\n  ships inside the `lattices` npm package.\n\nThese commands are routing. They do not move ownership into Lattices. The\ndestination table below names the process that does the work.\n\n## Processes and ports\n\n**Lattices 0.15.1, Action 0.4.0 and Voice 0.3.0 use private IPC for local APIs.** Remote\naccess uses a separate, opt-in gateway on the verified Tailscale address,\nport 9399 by default. Starting a product does not start that gateway.\n\nInstall matching app and CLI versions. Earlier releases use the\n[legacy ports](#published-release-ports); their TCP clients must be upgraded.\n\n### macOS\n\n\nThe default socket directory is `~/.lattices/run`, including Action's Chrome\nbridge. Canvas and Action's job service keep their sockets in their own\napplication-support directories.\n\n| Endpoint | Process | Owner | Replaces in earlier releases |\n|---------------------------|---------|-------|--------------------------------|\n| `lattices.sock` | `DaemonServer` in the menu bar app | Lattices | Local agent API on 9399 |\n| `voice.sock` | Embedded voice input runtime (Vox, through Hudson Voice) | Lattices | Voice runtime on 9398 |\n| `companion.sock` | Companion bridge in the menu bar app | Lattices | Companion listener on 5287 and its Bonjour advertisement |\n| `action.sock` | Action local agent | Action | Agent API on 4319 |\n| `action-chrome.sock` | Chrome extension bridge | Action | Chrome companion HTTP/WebSocket listener on 4321 |\n| `speech.sock` | Voice.app RPC server | Voice | Voice API on 9397 |\n| `blink.sock` in `CANVAS_HOME` (then `BLINK_HOME`, then the existing default) | Canvas.app | Canvas | Unchanged private socket |\n| `runtime/companion.sock` in Action's application support | Durable job service: jobs, artifacts and timeline | Action | Existing Unix socket; no fixed TCP port |\n| `gateway.sock` | Separate gateway process, local control | Lattices | New endpoint |\n| Tailscale address, TCP 9399 by default | Separate gateway process, remote RPC and companion relay | Lattices gateway | Direct remote connections to product runtimes |\n\nThe local APIs use filesystem permissions and authentication as described in\n[Private IPC and the gateway](#private-ipc-and-the-gateway).\nThe embedded voice runtime is available only in builds that include Hudson\nVoice. Voice input has no remote gateway route.\n\nThe Chrome extension uses native messaging over stdio. Its native host connects\nto the bridge through authenticated `action-chrome.sock`. Action's job service\nuses its own `runtime/companion.sock`. Lattices' device companion bridge uses\n`~/.lattices/run/companion.sock` for paired remote visits. These are three\nseparate services despite the shared companion name.\n\nPrivate IPC does not remove every network listener. Action-owned Chrome still\nuses an intentional loopback Chrome DevTools Protocol (CDP) port, 9335 for the\ndefault workspace. That browser endpoint is separate from Action's agent API\nand from the gateway.\n\n### Linux\n\n\nIn 0.15.1, `lattices-host` is a Bun process with\n`lattices.sock` and `companion.sock` in `~/.lattices/run`. The Quickshell app's\nbackend and the tray use the local host. Remote requests enter through the\nseparate gateway. The Linux host has no TCP compatibility listener.\n\n`host.describe` reports the host's platform, capabilities and methods.\nAction.app, Canvas.app and Voice.app remain macOS apps. Action's RemoteEngine\ncan call the Linux host's `computer.*` methods through the gateway. See\n[Install on Linux](/lattices/docs/install-linux) and\n[Remote engine](/action/docs/remote-engine). The macOS rollout does not establish\nLinux or physical iOS runtime verification.\n\n### Published-release ports\n\nThis table applies to Lattices 0.14.1, Action 0.3.1 and Voice 0.2.0.\nIt is retained for upgrade reference. Binding and authorization are separate boundaries.\n\n| Port | Process | Binding and authorization in earlier releases |\n|------|---------|-------------------------------------------------|\n| 9399 | Lattices `DaemonServer` on macOS | Loopback; core methods have no token check. Speaking verbs require the Voice capability token, which `lats` sends |\n| 9398 | Embedded Lattices voice runtime | Wildcard listener; non-loopback peers rejected after accept. Requests require the token in `~/Library/Application Support/Lattices/Voice/hudson-voice-runtime.json` |\n| 9397 | Voice.app RPC server | Loopback; token from `~/Library/Application Support/Speech/RPC/capability` in `x-lattices-speech-token`; browser origins refused |\n| 4319 | Action local agent | Loopback; token required for attention-mode `drive.begin`, `workspace.dragFile` and `workspace.cancelOperation`. Other methods have no token check |\n| 4321 | Action Chrome companion bridge | HTTP/WebSocket listener; the Chrome extension connected directly to the bridge |\n| 5287 | Lattices companion bridge on macOS | All IPv4 interfaces with Bonjour discovery; human-approved pairing and signed requests |\n| 9399 and 5287 | Linux `lattices-host` and companion bridge | Local host calls use loopback; the host also listens on its tailnet address when available. Remote devices must pair |\n| none | Blink.app local API | `blink.sock` in the `BLINK_HOME` directory, mode 0600 |\n\nIn Lattices 0.15.1, port 9399 belongs to the opt-in gateway by default. In\nversions before 0.15.1, it belongs to the host or daemon. Matching the port\nnumber alone does not make clients compatible.\n\n## Private IPC and the gateway\n\n> **Versions:** Lattices 0.15.1, Action 0.4.0 and Voice 0.3.0 use private IPC.\n> The [process table](#processes-and-ports) lists the default\n> endpoints. Local clients connect directly to each product. Remote clients\n> use the gateway and its approved method grants.\n\n**Local services use private Unix sockets.** The main Lattices, Action and\nVoice APIs use `~/.lattices/run`; Canvas keeps its socket in its own data directory,\n`~/Library/Application Support/Blink` by default. A nonempty `CANVAS_HOME`\noverrides that path, followed by a nonempty `BLINK_HOME`.\nSocket directories are mode `0700`, and sockets are mode `0600`. Services in `~/.lattices/run`\nwrite a random capability token beside the socket, also mode `0600`.\n\nWebSocket services require the token in `x-lattices-token` during the upgrade.\nThe companion bridge requires that header on its HTTP requests too. These\nservices refuse browser `Origin` headers and unexpected `Host` headers. The\nmacOS core, companion and Voice servers also check the connected peer's user\nID. Action and the Bun services rely on the private directory and token.\nCanvas uses a peer-user-ID check and private filesystem permissions without a\ntoken. This is an account boundary, not isolation between programs running as\nthe same user.\n\nThe product clients find their sockets and credentials. For example, `lats`\nand `daemonCall()` find the Lattices socket and token. A raw WebSocket client\nmust read the service's token file, connect to its socket, and send\n`Host: localhost` with the token. The token is a local capability: the shared\nclient sends it over private IPC by default. An explicit TCP target, even a\nloopback address that could be an SSH tunnel, receives no local token unless\nloopback compatibility is explicitly enabled. For the CLI and Pi client, that\nrequires `LATTICES_ALLOW_LOOPBACK_TCP=1`. Non-loopback targets never receive\nlocal tokens. Canvas keeps its newline JSON protocol and needs no HTTP headers\nor token.\n\n`LATTICES_RUNTIME_DIR` moves the runtime directory for every participating\nprocess. Use it for isolated tests, not for normal operation.\n\n**Loopback TCP is a compatibility switch, not a default.** These macOS\nruntimes can open a loopback port when explicitly configured. The Linux host\nhas no TCP compatibility listener.\n\n| Product | Variable | Port | Token still required |\n|---------|----------|------|----------------------|\n| Lattices on macOS | `LATTICES_ALLOW_LOOPBACK_TCP=1` | 9399 | Yes |\n| Voice | `LATTICES_SPEECH_TCP=1` | 9397 | Yes |\n| Action | `ACTION_ALLOW_TCP=1` | 4319 | Yes |\n\n**Each product keeps its own lifecycle.** Launching or quitting Lattices,\nAction, Canvas or Voice.app does not start, stop or restart another product, and\nit does not start the gateway. The CLI connects to whichever runtimes are\nrunning and reports the others as unavailable.\n\n**Remote RPC and companion access use the gateway, and only on request.**\nThe gateway is a separate per-user process. Start it explicitly:\n\n```bash\nlats gateway start --tailnet   # background, until `lats gateway stop`\nlats gateway status\n```\n\nIt does not install a login item or boot service. It requires Tailscale and\nbinds only this machine's verified Tailscale address, on port 9399 by default.\nThere is no LAN, wildcard or unauthenticated fallback. It admits devices owned\nby this machine's Tailscale user unless you set `--allow-user` or\n`--allow-tag`.\n\nA remote client pairs, and a person on this machine approves it:\n\n```bash\n# On the remote machine\nlats --host HOST pair --read-only\n# Or request exactly these methods instead of the read-only scope:\nlats --host HOST pair --grant action:status --grant speech:speech.enqueue\n\n# On this machine: check the fingerprint and grants, then approve\nlats gateway clients\nlats gateway approve CLIENT_ID\nlats gateway revoke CLIENT_ID\n```\n\nEvery stored grant names one exact `product:method`. `--read-only` requests\nthe reviewed core Lattices read methods, which exclude screen contents. Add\n`--screen` for screenshots, streams, OCR and terminal contents, or `--shell`\nfor typing and launching commands. Plain `pair` covers read, screen and act;\n`--drive` adds desktop input and shell. Clients paired earlier keep their\nexact grants. Repeated `--grant` flags request\nexactly the listed methods, from any product, instead of a scope. You cannot\ncombine them with `--read-only` or `--drive`. A request for a new capability\nneeds another local approval. Re-pairing with the same client key adds approved\ngrants to the existing set. To reduce access, revoke the client and pair again.\nOnly methods on the gateway's reviewed list can be granted; the gateway rejects\nunknown methods and never lets a remote client choose a socket path or call\nlocal administration. If a product is not running, the gateway reports it as\nunavailable. It does not start it.\n\nThese stay local and the gateway does not forward them:\n\n- raw computer action events and Canvas event streams;\n- Action's connection-owned drive leases and workspace drag/cancel operations;\n- Voice's native playback reservation and event streams;\n- the embedded voice-input runtime and core `voice.*` forwarding;\n- the VNC `capture.live` listener. The gateway exposes no VNC.\n\nRemote visits use the authenticated companion relay through the gateway port.\nCompanion device trust and gateway client trust are separate stores, and\nexisting direct-host RPC pairings must pair again with the gateway. Companion\ndevice pairings remain in the companion's own store. The native Machines/Hosts\nview reuses the CLI gateway pairing; a companion pairing alone does not unlock\nremote host status or screens. Canvas's paired iOS note sync remains a separate\nMultipeer Connectivity service.\n\n## Where commands go\n\nThis table names the destination in the current versions. Earlier releases\nhave the same product ownership but use the\n[older transports](#published-release-ports).\n\n| You run | Process that receives it | Method family | Process that does the work |\n|---------|--------------------------|---------------|----------------------------|\n| `lats tile`, `lats place`, `lats search`, `lats layer`, `lats call ...` | Lattices daemon via `lattices.sock` | `windows.*`, `spaces.*`, `layers.*`, ... | Lattices |\n| `lats start`, `lats sync` | tmux, directly | n/a | tmux, configured by Lattices |\n| `lats computer ...` | Lattices daemon via `lattices.sock` | `computer.*` | Lattices |\n| `lats voice listen`, `stopListening`, `simulate`, `intents` | Lattices daemon via `lattices.sock` | `voice.*` | Lattices; transcription uses the embedded runtime via `voice.sock` |\n| `lats voice say`, `stop`, `pause`, `resume`, `skip`, `seek`, `list`, `select` | Lattices daemon via `lattices.sock` | `voice.*` | Voice.app via `speech.sock`, as `speech.*` |\n| `lats action call <method>` | Action agent via `action.sock` | Action methods | Action |\n| `lats action <verb>` (other verbs) | Action CLI from a runtime bundle or checkout | n/a | Action |\n| `lats mcp` tools `browser_*` | `lats mcp` stdio process | n/a | Action Browser code in the `lattices` package, using Action-owned Chrome profiles |\n| `lats mcp` tools `host_*` | Local Lattices runtime via `lattices.sock`, or the gateway for a configured remote host | daemon protocol | Lattices on the selected host |\n| `lats --host <name> ...` | Separate gateway on the verified Tailscale address, port 9399 by default | Signed requests with approved method grants | The selected product runtime via its private socket |\n| `canvas ...` (or `blink ...`) | Note files on disk; `canvas desk` also uses `blink.sock` | File operations and newline-delimited JSON | Canvas.app watches the same files |\n\n### Current mismatches\n\nSome interfaces cross or overlap the product boundaries.\nRead these before choosing an interface.\n\n1. **`lats computer` is Lattices, not Action.** The `computer.*` methods run in\n   the Lattices daemon (see the `computer.*` entries in the\n   [API reference](/lattices/docs/api)). Action is the separate computer-use\n   product with its own agent on `action.sock`.\n2. **Action reaches Linux through Lattices.** Action's `RemoteEngine` drives a\n   Linux machine by calling `computer.*` on that machine's `lattices-host`.\n   Current versions reach it through the gateway and approved grants.\n   The native Action app remains on macOS. See\n   [Remote engine](/action/docs/remote-engine).\n3. **`lats voice` has two destinations.** Listening verbs stay in Lattices.\n   Speaking verbs are a bridge: the daemon renames each call (for example\n   `voice.say` becomes `speech.enqueue`) and forwards it to Voice.app through\n   `speech.sock`. If Voice.app is not running, the speaking verbs fail. There is no fallback\n   inside Lattices. See [Voice](/lattices/docs/voice) and\n   [Voice](/voice/docs/overview).\n\nThe Hands-Off worker is a separate case. It speaks its replies through the\nembedded voice runtime on `voice.sock`. It does not use Voice.app.\n\n## Glossary\n\n| Term | Definition |\n|------|------------|\n| **Command Bar** | The menu bar app's primary interface (**Control+Option+Space** by default). Search and run actions for apps, windows, workspaces, and settings. |\n| **Workspace layer** | A named group of windows that you switch as one unit with **⌃⌥⇧** arrows or `lats layer`. See [Layers](/lattices/docs/layers). Not the same as an Action private layer. |\n| **Window Tiling**     | Snap terminal windows to preset screen positions (halves, quarters, thirds, maximize, center). Works from the CLI (`lats tile`) or the command bar. |\n| **Agent API** | Lattices RPC methods and events hosted by the menu bar app on macOS or `lattices-host` on Linux. Current versions use authenticated WebSocket over `lattices.sock`; versions before 0.15.1 use TCP 9399. See the [API reference](/lattices/docs/api). |\n| **Private IPC** | Local communication over a Unix socket owned by the current user. Product APIs authenticate local clients before accepting requests; the gateway is not involved. |\n| **Gateway** | Separate, opt-in process for remote access. It verifies Tailscale identity and signed requests, checks approved product/method grants, and connects to the product through private IPC. |\n| **Agent** | Any program that calls the agent API autonomously: an AI coding agent, a shell script, a CI pipeline, or a custom tool. |\n| **Session** | A persistent tmux workspace that lives in the background. Survives terminal crashes, disconnects, and closing your laptop. One session per project. Requires tmux. |\n| **Pane** | A single terminal view inside a session. A typical setup has two panes side by side: shell on the left, dev server on the right. Requires tmux. |\n| **Attach / Detach** | Attaching connects your terminal to an existing session. Detaching disconnects but keeps the session alive. Your shell and dev server keep running. Requires tmux. |\n| **Sync / Reconcile**     | `lats sync` brings a running session back in line with its declared config. It recreates missing panes, re-applies layout, restores labels, re-runs commands in idle panes. Requires tmux. |\n| **Ensure / Prefill** | Two modes for restoring exited commands on reattach. **Ensure** auto-reruns the command. **Prefill** types it but waits for you to press Enter. Set via `.lattices.json`. Requires tmux. |\n| **tmux** | Terminal multiplexer (optional). Provides persistent sessions, pane layouts, and command restoration. Install with `brew install tmux` on macOS if you want session management. |\n\n## How it works\n\n1. You create a `.lattices.json` file in your project root (or run `lats init`)\n2. The menu bar app discovers the project and adds it to the command bar\n3. You can tile windows, switch layers, search via OCR, and use the agent API\n4. With tmux installed, `lats start` also creates persistent terminal sessions:\n   - Each pane gets its command (shell, dev server, tests, etc.)\n   - The session persists in the background until you kill it\n   - You can attach/detach from any terminal at any time\n   - If `ensure` is enabled, exited commands auto-restart on reattach\n\n## Core architecture\n\n- The menu bar app is the core on macOS. It provides the command bar,\n  window tiling, OCR, project discovery and the agent API. Versions from 0.15.1 expose\n  that API through `lattices.sock`; versions before 0.15.1 use TCP 9399. The app\n  works with or without tmux.\n- The `lats` CLI calls the agent API for windows, layers, search and OCR. It\n  runs `tmux` commands directly for session management.\n- Local agents and scripts connect to the agent API over WebSocket, using\n  private IPC in current versions. Remote access goes through the\n  gateway and is limited to approved methods. The API lets agents discover\n  projects, tile windows, switch layers, read on-screen text and subscribe to real-time events. See the\n  [API reference](/lattices/docs/api).\n- The app and the CLI share one session naming convention, so they always\n  agree on which session belongs to which project.\n\n### Session naming\n\nA session name is `<basename>-<hash>`, where:\n\n- `basename` is the project directory name (non-alphanumeric chars replaced with `-`)\n- `hash` is the first 6 hex chars of the SHA-256 of the full absolute path\n\nThis guarantees unique session names even if two projects share the\nsame directory name (e.g. `~/work/app` and `~/personal/app`).\n\nBoth the CLI (Node.js `crypto.createHash`) and the app (Swift\n`CryptoKit.SHA256`) produce identical hashes.\n\n### Window discovery via title tags\n\nWhen lattices creates a tmux session, it sets the tmux option:\n\n```\nset-titles-string \"[lattices:<session-name>] #{pane_title}\"\n```\n\nThis embeds a `[lattices:name]` tag in the terminal window title. The\nmenu bar app uses this tag to find windows via three fallback paths:\n\n1. **CGWindowList** (needs Screen Recording permission): fastest,\n   reads window names from the window server\n2. **Accessibility API** (needs Accessibility permission): queries\n   AXUIElement window titles for the terminal app\n3. **AppleScript**: iterates Terminal.app or iTerm2 windows by name\n\n### Space switching via SkyLight\n\nThe menu bar app can switch macOS Spaces to reach a session's window.\nIt uses private SkyLight framework APIs loaded at runtime via `dlopen`:\n\n- `CGSMainConnectionID`: get the connection to the window server\n- `CGSGetActiveSpace`: current Space ID\n- `CGSCopyManagedDisplaySpaces`: enumerate all Spaces per display\n- `SLSCopySpacesForWindows`: find which Space a window is on\n- `SLSManagedDisplaySetCurrentSpace`: switch a display to a Space\n\nThis is the same approach used by [Loop](https://github.com/MrKai77/Loop)\nand other macOS window managers.\n\n### Ensure/prefill restoration (requires tmux)\n\nWhen you run `lats start` and a session already exists:\n\n1. lattices checks the `ensure` / `prefill` flag in `.lattices.json`\n2. For each pane, it queries `#{pane_current_command}` via tmux\n3. If the pane is running a shell (bash, zsh, fish, sh, dash), the original\n   command has exited. lattices then does one of these:\n   - **ensure**: sends the command + Enter (auto-restart)\n   - **prefill**: sends the command without Enter (manual restart)\n4. Then it attaches to the session as normal\n\n## Agent control\n\nThe agent API gives agents the same control as a human using the\nmenu bar app. An agent can list projects and windows, launch sessions,\ntile windows to screen positions, subscribe to real-time events\n(`windows.changed`, `tmux.changed`, `layer.switched`), and sync\nsessions back to their declared config.\n\nA typical orchestrator sets up a multi-project workspace in a few\n`daemonCall()` invocations. See the [Agent API reference](/lattices/docs/api)\nfor the full method list and code examples.\n\n## Key shortcuts (inside tmux)\n\nThese work when you're inside a tmux session:\n\n| Shortcut       | Action                |\n|----------------|-----------------------|\n| Ctrl+B  D      | Detach from session   |\n| Ctrl+B  X      | Kill current pane     |\n| Ctrl+B  Left   | Move to left pane     |\n| Ctrl+B  Right  | Move to right pane    |\n| Ctrl+B  Up     | Move to pane above    |\n| Ctrl+B  Down   | Move to pane below    |\n| Ctrl+B  Z      | Zoom pane (toggle)    |\n| Ctrl+B  [      | Scroll mode (q exits) |\n\nThe prefix `Ctrl+B` means: hold Control, press B, release both,\nthen press the next key."
    },
    {
      "id": "lattices:guide-sessions",
      "slug": "guide-sessions",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Sessions",
      "description": "How Lattices turns a project directory into a tmux session, and how to declare, start, repair and find one",
      "order": 1.1,
      "sourcePath": "docs/guide-sessions.md",
      "url": "/lattices/docs/guide-sessions",
      "rawUrl": "/lattices/docs/markdown/guide-sessions.md",
      "markdownUrl": "/lattices/docs/guide-sessions.md",
      "headings": [
        {
          "depth": 2,
          "text": "How a session is named",
          "anchor": "how-a-session-is-named"
        },
        {
          "depth": 2,
          "text": "Declare the panes",
          "anchor": "declare-the-panes"
        },
        {
          "depth": 2,
          "text": "Start, detach, reattach",
          "anchor": "start-detach-reattach"
        },
        {
          "depth": 2,
          "text": "See what is running",
          "anchor": "see-what-is-running"
        },
        {
          "depth": 2,
          "text": "Repair a session",
          "anchor": "repair-a-session"
        },
        {
          "depth": 2,
          "text": "Find the session's window",
          "anchor": "find-the-session-s-window"
        },
        {
          "depth": 2,
          "text": "Exit codes",
          "anchor": "exit-codes"
        },
        {
          "depth": 2,
          "text": "Related",
          "anchor": "related"
        }
      ],
      "tokensEstimate": 1102,
      "frontmatter": {
        "title": "Sessions",
        "description": "How Lattices turns a project directory into a tmux session, and how to declare, start, repair and find one",
        "order": 1.1
      },
      "markdown": "---\ntitle: Sessions\ndescription: How Lattices turns a project directory into a tmux session, and how to declare, start, repair and find one\norder: 1.1\n---\n\nA session is a tmux session that Lattices creates for one project\ndirectory. The project declares its panes in a `.lattices.json` file.\n`lats start` builds the panes, runs their commands and attaches your\nterminal. The session keeps running after you detach or close the terminal.\n\nSessions need tmux (`brew install tmux`). Window tiling, search, layers and\nthe agent API work without tmux.\n\nThe examples use an invented project, `tideline`, at `~/dev/tideline`.\n\n## How a session is named\n\nLattices derives the session name from the directory:\n\n```\n<directory basename>-<first 6 hex characters of sha256(absolute path)>\n```\n\n`~/dev/tideline` becomes something like `tideline-3f9a1c`. The hash makes two\ncheckouts with the same basename distinct. Lattices tags the terminal\nwindow's title with `[lattices:tideline-3f9a1c]`, which is how the app and\n`lats search` find the window later.\n\n## Declare the panes\n\nCreate a starter config:\n\n```bash\ncd ~/dev/tideline\nlats init\n```\n\n`lats init` writes `.lattices.json` with `\"ensure\": true` and either two\npanes or one. It writes two panes when `package.json` has a `dev`, `start`,\n`serve` or `watch` script, checked in that order, and it picks the package\nmanager by lockfile: bun, then pnpm, then yarn, then npm. Without such a\nscript it writes a single shell pane.\n\nA config for tideline with a shell, a dev server and a test watcher:\n\n```json\n{\n  \"ensure\": true,\n  \"panes\": [\n    { \"name\": \"shell\", \"size\": 60 },\n    { \"name\": \"server\", \"cmd\": \"bun dev\" },\n    { \"name\": \"tests\", \"cmd\": \"bun test --watch\" }\n  ]\n}\n```\n\n| Field | Meaning |\n|-------|---------|\n| `panes[].name` | Label shown in the app. Also the target for `lats restart <name>`. |\n| `panes[].cmd` | Shell command to run when the pane opens. Omit it for a plain shell. |\n| `panes[].size` | Width of the first pane as a percentage, 1 to 99. Default 60. Ignored on other panes. |\n| `ensure` | On reattach, re-run a pane's command when its process has exited and the shell is idle. |\n| `prefill` | On reattach, type the exited command into the pane without pressing Return. |\n\n`ensure` and `prefill` are exclusive. When both are set, `ensure` wins.\n\nThe pane count picks the layout:\n\n| Panes | Layout |\n|-------|--------|\n| 1 | One full-width pane |\n| 2 | Side by side. The first pane takes `size` percent of the width. |\n| 3 or more | The first pane takes the left side. The rest stack on the right. |\n\nA directory with no `.lattices.json` still starts. Lattices uses the same\ntwo-pane or one-pane default that `lats init` would write, without saving it.\n\n## Start, detach, reattach\n\n```bash\ncd ~/dev/tideline\nlats start\n```\n\nExpected: tmux opens with the declared panes and each `cmd` running. If the\nsession already exists, `lats start` reattaches instead of rebuilding it.\n\nDetach with Ctrl+B then D. The panes and their processes keep running.\n\nRun `lats start` again from the same directory to reattach. With `ensure`\nset, a pane whose command has exited restarts on reattach. With `prefill`\nset, the command is typed into the pane for you to review and run.\n\n`lats` on its own prints the directory's session name, config and app\nstatus. It does not attach.\n\n`lats tmux` is an alias for `lats start`.\n\n## See what is running\n\n```bash\nlats ls          # tmux sessions, with their tab group when they have one\nlats status      # managed and unmanaged sessions\nlats sessions    # sessions as the daemon sees them; --json for scripts\n```\n\n`lats ls` prints `No active sessions.` when tmux has none.\n\n## Repair a session\n\nRestart one pane's command:\n\n```bash\nlats restart server   # by name, case-insensitive\nlats restart 1        # by 0-based index\nlats restart          # pane 0\n```\n\n`lats restart` sends Ctrl-C, waits half a second, escalates to SIGKILL on the\npane's child processes if needed, then sends the declared command.\n\nRebuild missing panes without killing the session:\n\n```bash\nlats sync\n```\n\n`lats sync` counts the panes, recreates missing ones, reapplies the layout,\nrestores the labels and re-runs declared commands in idle panes. Use it\nafter you closed a pane by hand.\n\nKill a session:\n\n```bash\nlats kill            # the current directory's session\nlats kill tideline-3f9a1c\n```\n\n## Find the session's window\n\n```bash\nlats focus tideline-3f9a1c\n```\n\n`lats focus` raises the terminal window that carries the session tag and\nswitches to its Space when needed. Use `lats search tideline --deep` when\nyou do not know the session name; see\n[Finding windows](/lattices/docs/guide-search).\n\nThe command bar (Control+Option+Space) lists every project that has a\n`.lattices.json`. The app scans `~/dev`, `~/Developer`, `~/projects` and\n`~/src` three levels deep for those files.\n\n## Exit codes\n\n| Code | Meaning |\n|------|---------|\n| 0 | Success |\n| 1 | General error, such as missing arguments or a bad config |\n| 2 | Session not found |\n\n## Related\n\n- [Configuration](/lattices/docs/config): the full `.lattices.json` and CLI\n  reference.\n- [Layers](/lattices/docs/guide-layers): group a session's terminal with its\n  editor and browser.\n- [Concepts](/lattices/docs/concepts#session-naming): how naming and window\n  discovery work underneath.\n",
      "content": "A session is a tmux session that Lattices creates for one project\ndirectory. The project declares its panes in a `.lattices.json` file.\n`lats start` builds the panes, runs their commands and attaches your\nterminal. The session keeps running after you detach or close the terminal.\n\nSessions need tmux (`brew install tmux`). Window tiling, search, layers and\nthe agent API work without tmux.\n\nThe examples use an invented project, `tideline`, at `~/dev/tideline`.\n\n## How a session is named\n\nLattices derives the session name from the directory:\n\n```\n<directory basename>-<first 6 hex characters of sha256(absolute path)>\n```\n\n`~/dev/tideline` becomes something like `tideline-3f9a1c`. The hash makes two\ncheckouts with the same basename distinct. Lattices tags the terminal\nwindow's title with `[lattices:tideline-3f9a1c]`, which is how the app and\n`lats search` find the window later.\n\n## Declare the panes\n\nCreate a starter config:\n\n```bash\ncd ~/dev/tideline\nlats init\n```\n\n`lats init` writes `.lattices.json` with `\"ensure\": true` and either two\npanes or one. It writes two panes when `package.json` has a `dev`, `start`,\n`serve` or `watch` script, checked in that order, and it picks the package\nmanager by lockfile: bun, then pnpm, then yarn, then npm. Without such a\nscript it writes a single shell pane.\n\nA config for tideline with a shell, a dev server and a test watcher:\n\n```json\n{\n  \"ensure\": true,\n  \"panes\": [\n    { \"name\": \"shell\", \"size\": 60 },\n    { \"name\": \"server\", \"cmd\": \"bun dev\" },\n    { \"name\": \"tests\", \"cmd\": \"bun test --watch\" }\n  ]\n}\n```\n\n| Field | Meaning |\n|-------|---------|\n| `panes[].name` | Label shown in the app. Also the target for `lats restart <name>`. |\n| `panes[].cmd` | Shell command to run when the pane opens. Omit it for a plain shell. |\n| `panes[].size` | Width of the first pane as a percentage, 1 to 99. Default 60. Ignored on other panes. |\n| `ensure` | On reattach, re-run a pane's command when its process has exited and the shell is idle. |\n| `prefill` | On reattach, type the exited command into the pane without pressing Return. |\n\n`ensure` and `prefill` are exclusive. When both are set, `ensure` wins.\n\nThe pane count picks the layout:\n\n| Panes | Layout |\n|-------|--------|\n| 1 | One full-width pane |\n| 2 | Side by side. The first pane takes `size` percent of the width. |\n| 3 or more | The first pane takes the left side. The rest stack on the right. |\n\nA directory with no `.lattices.json` still starts. Lattices uses the same\ntwo-pane or one-pane default that `lats init` would write, without saving it.\n\n## Start, detach, reattach\n\n```bash\ncd ~/dev/tideline\nlats start\n```\n\nExpected: tmux opens with the declared panes and each `cmd` running. If the\nsession already exists, `lats start` reattaches instead of rebuilding it.\n\nDetach with Ctrl+B then D. The panes and their processes keep running.\n\nRun `lats start` again from the same directory to reattach. With `ensure`\nset, a pane whose command has exited restarts on reattach. With `prefill`\nset, the command is typed into the pane for you to review and run.\n\n`lats` on its own prints the directory's session name, config and app\nstatus. It does not attach.\n\n`lats tmux` is an alias for `lats start`.\n\n## See what is running\n\n```bash\nlats ls          # tmux sessions, with their tab group when they have one\nlats status      # managed and unmanaged sessions\nlats sessions    # sessions as the daemon sees them; --json for scripts\n```\n\n`lats ls` prints `No active sessions.` when tmux has none.\n\n## Repair a session\n\nRestart one pane's command:\n\n```bash\nlats restart server   # by name, case-insensitive\nlats restart 1        # by 0-based index\nlats restart          # pane 0\n```\n\n`lats restart` sends Ctrl-C, waits half a second, escalates to SIGKILL on the\npane's child processes if needed, then sends the declared command.\n\nRebuild missing panes without killing the session:\n\n```bash\nlats sync\n```\n\n`lats sync` counts the panes, recreates missing ones, reapplies the layout,\nrestores the labels and re-runs declared commands in idle panes. Use it\nafter you closed a pane by hand.\n\nKill a session:\n\n```bash\nlats kill            # the current directory's session\nlats kill tideline-3f9a1c\n```\n\n## Find the session's window\n\n```bash\nlats focus tideline-3f9a1c\n```\n\n`lats focus` raises the terminal window that carries the session tag and\nswitches to its Space when needed. Use `lats search tideline --deep` when\nyou do not know the session name; see\n[Finding windows](/lattices/docs/guide-search).\n\nThe command bar (Control+Option+Space) lists every project that has a\n`.lattices.json`. The app scans `~/dev`, `~/Developer`, `~/projects` and\n`~/src` three levels deep for those files.\n\n## Exit codes\n\n| Code | Meaning |\n|------|---------|\n| 0 | Success |\n| 1 | General error, such as missing arguments or a bad config |\n| 2 | Session not found |\n\n## Related\n\n- [Configuration](/lattices/docs/config): the full `.lattices.json` and CLI\n  reference.\n- [Layers](/lattices/docs/guide-layers): group a session's terminal with its\n  editor and browser.\n- [Concepts](/lattices/docs/concepts#session-naming): how naming and window\n  discovery work underneath."
    },
    {
      "id": "lattices:guide-tiling",
      "slug": "guide-tiling",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Tiling and placement",
      "description": "Move windows to named positions with keys, the CLI or the daemon, target an exact window, and undo a bad move",
      "order": 1.2,
      "sourcePath": "docs/guide-tiling.md",
      "url": "/lattices/docs/guide-tiling",
      "rawUrl": "/lattices/docs/markdown/guide-tiling.md",
      "markdownUrl": "/lattices/docs/guide-tiling.md",
      "headings": [
        {
          "depth": 2,
          "text": "Placement names",
          "anchor": "placement-names"
        },
        {
          "depth": 2,
          "text": "Tile with a key",
          "anchor": "tile-with-a-key"
        },
        {
          "depth": 2,
          "text": "Tile with the CLI",
          "anchor": "tile-with-the-cli"
        },
        {
          "depth": 2,
          "text": "Target an exact window",
          "anchor": "target-an-exact-window"
        },
        {
          "depth": 2,
          "text": "Find and place in one step",
          "anchor": "find-and-place-in-one-step"
        },
        {
          "depth": 2,
          "text": "Arrange several windows",
          "anchor": "arrange-several-windows"
        },
        {
          "depth": 2,
          "text": "Undo a bad move",
          "anchor": "undo-a-bad-move"
        },
        {
          "depth": 2,
          "text": "From the daemon",
          "anchor": "from-the-daemon"
        },
        {
          "depth": 2,
          "text": "Related",
          "anchor": "related"
        }
      ],
      "tokensEstimate": 1156,
      "frontmatter": {
        "title": "Tiling and placement",
        "description": "Move windows to named positions with keys, the CLI or the daemon, target an exact window, and undo a bad move",
        "order": 1.2
      },
      "markdown": "---\ntitle: Tiling and placement\ndescription: Move windows to named positions with keys, the CLI or the daemon, target an exact window, and undo a bad move\norder: 1.2\n---\n\nA placement is a named region of a display. Lattices moves a window to a\nplacement from a key, from `lats tile`, or from a daemon call. This page\nnames the placements, shows each route, and explains how to target one exact\nwindow instead of the front one.\n\nTiling needs the menu bar app running with Accessibility permission. Every\nplacement respects the menu bar and the Dock: it uses the visible desktop\narea, not the whole screen.\n\n## Placement names\n\n| Name | Area |\n|------|------|\n| `left`, `right`, `top`, `bottom` | A half |\n| `top-left`, `top-right`, `bottom-left`, `bottom-right` | A quarter |\n| `left-third`, `center-third`, `right-third` | A vertical third |\n| `maximize` | The whole visible area |\n| `center` | 80% of the width and height, centered |\n| `CxR:c,r` | One cell of a C-by-R grid, counted from 1 at the top left. `4x4:1,2` is the first column, second row. |\n| `grid:CxR:c,r` | The same cell counted from 0. |\n\n`left-half`, `right-half`, `top-half`, `bottom-half` and `max` are aliases.\n[Tiling reference](/lattices/docs/tiling-reference) lists further named\npositions such as sixths and eighths.\n\n## Tile with a key\n\n1. Bring the window to the front.\n2. Press Control+Option and an arrow key.\n\nExpected: the window fills that half of its display.\n\nFor pointer placement, enable Tile HUD in Settings. Then hold Control+Option\nand move the pointer to aim a placement with the on-screen picker; release to\ntile, or keep the pointer centered to cancel. Pointer placement is off by default.\n\nThe other default placement keys:\n\n| Key | Placement |\n|-----|-----------|\n| Control+Option+Return | Maximize |\n| Control+Option+C | Center |\n| Control+Option+U / I / J / K | Top-left, top-right, bottom-left, bottom-right quarter |\n| Control+Option+Shift+1 / 2 / 3 | Left, center, right third |\n| With Tile HUD enabled, hold Control+Option and press 1 to 9 | That cell of the 3×3 matrix, laid out like a numpad. 5 maximizes |\n| Control+Option+D | Distribute every visible window |\n\nSettings > Shortcuts lists the full set and lets you change any of them. See\n[Menu bar app](/lattices/docs/app#shortcuts).\n\n## Tile with the CLI\n\n`lats tile` moves the front window:\n\n```bash\nlats tile left\nlats tile bottom-right\nlats tile 3x3:2,2        # the centre cell of a 3-by-3 grid\n```\n\nExpected: the window moves and the command prints a receipt from the daemon.\nWhen the app is not running, `lats tile` falls back to AppleScript on the\nprimary display and says so. The fallback cannot target other displays or\nspecific windows.\n\n## Target an exact window\n\n`lats tile` always takes the front window. To move a specific window, find\nits window id first:\n\n```bash\nlats windows          # every visible window with its wid\nlats map              # the same, drawn per display; --json for scripts\n```\n\nThen place it by id:\n\n```bash\nlats window place 4182 top-left                    # on its current display\nlats window move 4182 --display 1                  # keep its relative frame\nlats window move 4182 --display 1 --placement right\nlats window focus 4182\n```\n\nAdd `--dry-run --json` to see the plan without moving anything:\n\n```bash\nlats window move 4182 --display 0 --dry-run --json\n```\n\nA malformed wid is an error. These commands never fall back to the front\nwindow.\n\n## Find and place in one step\n\n`lats place` searches for a window, focuses it and tiles it:\n\n```bash\nlats place tideline bottom-right\nlats place \"tideline dev\" left --dry-run\n```\n\nIt uses deep search, so a terminal whose tabs sit in `~/dev/tideline` matches\neven when the word is not in the window title. See\n[Finding windows](/lattices/docs/guide-search).\n\n## Arrange several windows\n\n```bash\nlats tile family                     # grid every window of the front app\nlats tile family \"Google Chrome\"     # grid one app's windows\nlats tile family right               # keep the grid in the right half\nlats distribute                      # grid every visible window\nlats distribute Ghostty left\n```\n\nPress Hyper+G for the same job with the pointer: select windows on the\ndesktop, then Fill, Grid, Swap or Place them. Keep changes with Return;\nrestore the original frames with Escape. Hyper is Control+Option+Shift+\nCommand, and holding Caps Lock sends it by default.\n\n## Undo a bad move\n\nLattices records a desktop map after every change and keeps them for 72\nhours:\n\n```bash\nlats state                       # recent maps\nlats state show                  # the latest one\nlats state restore <id> --plan   # what a restore would do\nlats state restore <id>\n```\n\n`lats state restore` puts windows back on the desktops and frames the map\nhas. It never changes display settings.\n\nBefore a risky change, name the current state:\n\n```bash\nlats state save before-demo\nlats state restore before-demo\n```\n\n## From the daemon\n\nAgents and scripts call `windows.place`:\n\n```bash\nlats call windows.place '{\"wid\":4182,\"placement\":\"left\"}'\nlats call windows.place '{\"wid\":4182,\"placement\":{\"kind\":\"grid\",\"columns\":3,\"rows\":2,\"column\":0,\"row\":1}}'\nlats call windows.place '{\"wid\":4182,\"placement\":\"left\",\"dryRun\":true}'\n```\n\nA placement is a plain name or a typed object with `kind` set to `tile`,\n`grid` or `fractions`. `windows.resolve` tells you which window a target\nwould pick before you move it. [Agent guide](/lattices/docs/agents#placement-contract)\nhas the contract; [Agent API](/lattices/docs/api) has every method.\n\n## Related\n\n- [Configuration](/lattices/docs/config#tile-positions): the CLI reference\n  for `lats tile` and `lats window`.\n- [Layers](/lattices/docs/guide-layers): give each window a placement that\n  Lattices restores when the layer switches in.\n",
      "content": "A placement is a named region of a display. Lattices moves a window to a\nplacement from a key, from `lats tile`, or from a daemon call. This page\nnames the placements, shows each route, and explains how to target one exact\nwindow instead of the front one.\n\nTiling needs the menu bar app running with Accessibility permission. Every\nplacement respects the menu bar and the Dock: it uses the visible desktop\narea, not the whole screen.\n\n## Placement names\n\n| Name | Area |\n|------|------|\n| `left`, `right`, `top`, `bottom` | A half |\n| `top-left`, `top-right`, `bottom-left`, `bottom-right` | A quarter |\n| `left-third`, `center-third`, `right-third` | A vertical third |\n| `maximize` | The whole visible area |\n| `center` | 80% of the width and height, centered |\n| `CxR:c,r` | One cell of a C-by-R grid, counted from 1 at the top left. `4x4:1,2` is the first column, second row. |\n| `grid:CxR:c,r` | The same cell counted from 0. |\n\n`left-half`, `right-half`, `top-half`, `bottom-half` and `max` are aliases.\n[Tiling reference](/lattices/docs/tiling-reference) lists further named\npositions such as sixths and eighths.\n\n## Tile with a key\n\n1. Bring the window to the front.\n2. Press Control+Option and an arrow key.\n\nExpected: the window fills that half of its display.\n\nFor pointer placement, enable Tile HUD in Settings. Then hold Control+Option\nand move the pointer to aim a placement with the on-screen picker; release to\ntile, or keep the pointer centered to cancel. Pointer placement is off by default.\n\nThe other default placement keys:\n\n| Key | Placement |\n|-----|-----------|\n| Control+Option+Return | Maximize |\n| Control+Option+C | Center |\n| Control+Option+U / I / J / K | Top-left, top-right, bottom-left, bottom-right quarter |\n| Control+Option+Shift+1 / 2 / 3 | Left, center, right third |\n| With Tile HUD enabled, hold Control+Option and press 1 to 9 | That cell of the 3×3 matrix, laid out like a numpad. 5 maximizes |\n| Control+Option+D | Distribute every visible window |\n\nSettings > Shortcuts lists the full set and lets you change any of them. See\n[Menu bar app](/lattices/docs/app#shortcuts).\n\n## Tile with the CLI\n\n`lats tile` moves the front window:\n\n```bash\nlats tile left\nlats tile bottom-right\nlats tile 3x3:2,2        # the centre cell of a 3-by-3 grid\n```\n\nExpected: the window moves and the command prints a receipt from the daemon.\nWhen the app is not running, `lats tile` falls back to AppleScript on the\nprimary display and says so. The fallback cannot target other displays or\nspecific windows.\n\n## Target an exact window\n\n`lats tile` always takes the front window. To move a specific window, find\nits window id first:\n\n```bash\nlats windows          # every visible window with its wid\nlats map              # the same, drawn per display; --json for scripts\n```\n\nThen place it by id:\n\n```bash\nlats window place 4182 top-left                    # on its current display\nlats window move 4182 --display 1                  # keep its relative frame\nlats window move 4182 --display 1 --placement right\nlats window focus 4182\n```\n\nAdd `--dry-run --json` to see the plan without moving anything:\n\n```bash\nlats window move 4182 --display 0 --dry-run --json\n```\n\nA malformed wid is an error. These commands never fall back to the front\nwindow.\n\n## Find and place in one step\n\n`lats place` searches for a window, focuses it and tiles it:\n\n```bash\nlats place tideline bottom-right\nlats place \"tideline dev\" left --dry-run\n```\n\nIt uses deep search, so a terminal whose tabs sit in `~/dev/tideline` matches\neven when the word is not in the window title. See\n[Finding windows](/lattices/docs/guide-search).\n\n## Arrange several windows\n\n```bash\nlats tile family                     # grid every window of the front app\nlats tile family \"Google Chrome\"     # grid one app's windows\nlats tile family right               # keep the grid in the right half\nlats distribute                      # grid every visible window\nlats distribute Ghostty left\n```\n\nPress Hyper+G for the same job with the pointer: select windows on the\ndesktop, then Fill, Grid, Swap or Place them. Keep changes with Return;\nrestore the original frames with Escape. Hyper is Control+Option+Shift+\nCommand, and holding Caps Lock sends it by default.\n\n## Undo a bad move\n\nLattices records a desktop map after every change and keeps them for 72\nhours:\n\n```bash\nlats state                       # recent maps\nlats state show                  # the latest one\nlats state restore <id> --plan   # what a restore would do\nlats state restore <id>\n```\n\n`lats state restore` puts windows back on the desktops and frames the map\nhas. It never changes display settings.\n\nBefore a risky change, name the current state:\n\n```bash\nlats state save before-demo\nlats state restore before-demo\n```\n\n## From the daemon\n\nAgents and scripts call `windows.place`:\n\n```bash\nlats call windows.place '{\"wid\":4182,\"placement\":\"left\"}'\nlats call windows.place '{\"wid\":4182,\"placement\":{\"kind\":\"grid\",\"columns\":3,\"rows\":2,\"column\":0,\"row\":1}}'\nlats call windows.place '{\"wid\":4182,\"placement\":\"left\",\"dryRun\":true}'\n```\n\nA placement is a plain name or a typed object with `kind` set to `tile`,\n`grid` or `fractions`. `windows.resolve` tells you which window a target\nwould pick before you move it. [Agent guide](/lattices/docs/agents#placement-contract)\nhas the contract; [Agent API](/lattices/docs/api) has every method.\n\n## Related\n\n- [Configuration](/lattices/docs/config#tile-positions): the CLI reference\n  for `lats tile` and `lats window`.\n- [Layers](/lattices/docs/guide-layers): give each window a placement that\n  Lattices restores when the layer switches in."
    },
    {
      "id": "lattices:guide-search",
      "slug": "guide-search",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Finding windows",
      "description": "The search tiers in Lattices, when to use each one, and how to turn a search result into a focused or tiled window",
      "order": 1.3,
      "sourcePath": "docs/guide-search.md",
      "url": "/lattices/docs/guide-search",
      "rawUrl": "/lattices/docs/markdown/guide-search.md",
      "markdownUrl": "/lattices/docs/guide-search.md",
      "headings": [
        {
          "depth": 2,
          "text": "Tier 1: index search",
          "anchor": "tier-1-index-search"
        },
        {
          "depth": 2,
          "text": "Tier 2: deep search",
          "anchor": "tier-2-deep-search"
        },
        {
          "depth": 2,
          "text": "Tier 3: screen text",
          "anchor": "tier-3-screen-text"
        },
        {
          "depth": 2,
          "text": "Act on a result",
          "anchor": "act-on-a-result"
        },
        {
          "depth": 2,
          "text": "Choose a tier",
          "anchor": "choose-a-tier"
        },
        {
          "depth": 2,
          "text": "From the daemon",
          "anchor": "from-the-daemon"
        }
      ],
      "tokensEstimate": 1047,
      "frontmatter": {
        "title": "Finding windows",
        "description": "The search tiers in Lattices, when to use each one, and how to turn a search result into a focused or tiled window",
        "order": 1.3
      },
      "markdown": "---\ntitle: Finding windows\ndescription: The search tiers in Lattices, when to use each one, and how to turn a search result into a focused or tiled window\norder: 1.3\n---\n\nLattices finds windows at three depths. Index search is fast and usually\nenough. Deep search inspects terminals live. Screen-text search reads the\nwords inside windows. This page says what each tier looks at, how to call it,\nand when to step up.\n\nEvery tier needs the menu bar app running.\n\n## Tier 1: index search\n\n```bash\nlats search tideline\n```\n\n`lats search` calls the daemon's `windows.search` method. It matches the\nquery against each window's title, app name, session tag and, when\nscreen-text search is on, the text the window shows. It reads data the app\nalready holds, so it returns at once.\n\nResults are ranked. A title or session match scores 3, an app match 2, and a\nscreen-text match 1. Recent windows rank higher; add `--no-recency` to turn\nthat off.\n\nExpected: a list of windows with their window ids, apps and titles, best\nfirst. `No results for \"tideline\"` means no indexed field contains the word.\n\nNarrow the sources when you need to:\n\n```bash\nlats search tideline --quick      # titles, apps and sessions only\nlats search tideline --terminal   # terminal tabs only\n```\n\n## Tier 2: deep search\n\n```bash\nlats search tideline --deep\n```\n\n`--deep` (or `--all`) runs the index search, then inspects terminal windows\nthrough the daemon's `terminals.search` method: each tab's working\ndirectory, title, tmux session and running command. A terminal whose tabs\nsit in `~/dev/tideline` matches even when \"tideline\" is not in its window\ntitle. Each matching tab adds to the window's score, so a terminal with four\ntideline tabs outranks one with a single tab.\n\nDeep search reads tabs from iTerm2 and Terminal.app. It also reads Ghostty\ntabs when Automation access to Ghostty is already granted, and kitty tabs\nwhen `allow_remote_control` and `listen_on` are set in `kitty.conf`. Warp,\nAlacritty and other terminals are matched from their shell processes: to one\nwindow when the terminal has one titled window or a title names the shell's\ndirectory, and otherwise to the app as a whole. `lats place` does not act on\nan app-wide match alone.\n\nUse deep search when you look for a project by name and the project name\nlives in a directory path rather than a title. Use index search first; deep\nsearch costs a live inspection of every terminal.\n\n## Tier 3: screen text\n\nScreen-text search needs Screen Recording permission and **Search & OCR** on\nin Settings. It is off by default. With it on, the app reads text from\nvisible windows and keeps the latest results, which tier 1 then matches.\n\nSee what the app has read now:\n\n```bash\nlats scan            # the latest text per visible window\nlats scan --full     # everything, not a summary\nlats scan deep       # run a deep read of background windows now\n```\n\nSearch the stored history with full-text syntax:\n\n```bash\nlats scan search \"error OR failed\"\nlats scan history 4182     # every read of one window\nlats scan recent 10\n```\n\n`lats scan search` and `lats scan history` read a SQLite database at\n`~/.lattices/ocr.db`, which keeps three days of scans. The free build keeps\nonly the in-memory snapshot, so these two commands work in bundle builds\nonly. [Screen OCR and search](/lattices/docs/ocr) covers scan timing and\naccuracy modes.\n\n## Act on a result\n\nGo to the top match:\n\n```bash\nlats place tideline            # focus and tile bottom-right\nlats place tideline left\nlats place tideline left --dry-run --json\n```\n\n`lats place` runs a deep search, focuses the first result and places it.\nPass `--dry-run` to see which window it would pick before it moves.\n\nPipe window ids into other commands:\n\n```bash\nlats search tideline --wid\nlats window place $(lats search tideline --wid | head -1) right\n```\n\nGet structured results for a script:\n\n```bash\nlats search tideline --json\n```\n\nEach entry has `wid`, `app`, `title`, `score`, `reasons`, `tabs` and\n`ocrSnippet`.\n\nRaise a session's terminal by session name:\n\n```bash\nlats focus tideline-3f9a1c\n```\n\n## Choose a tier\n\n| You want | Use |\n|----------|-----|\n| A window by a word in its title or app | `lats search <word>` |\n| A terminal working in a project directory | `lats search <project> --deep` |\n| The window and tile it | `lats place <word> [position]` |\n| Windows showing an error message | `lats search <message>` with Search & OCR on |\n| What a window showed earlier today | `lats scan history <wid>` (bundle builds) |\n\nThe command bar (Control+Option+Space) searches the same index with the same\nscoring and shows screen-text matches as \"Screen Text\" results.\n\n## From the daemon\n\n```bash\nlats call windows.search '{\"query\":\"tideline\"}'\nlats call terminals.search '{\"query\":\"tideline\"}'\nlats call ocr.snapshot '{}'\n```\n\n`windows.search` returns window objects with `matchSource` and\n`ocrSnippet`. `terminals.search` returns one entry per terminal tab with its\n`windowId`. [Agent API](/lattices/docs/api) documents the parameters.\n",
      "content": "Lattices finds windows at three depths. Index search is fast and usually\nenough. Deep search inspects terminals live. Screen-text search reads the\nwords inside windows. This page says what each tier looks at, how to call it,\nand when to step up.\n\nEvery tier needs the menu bar app running.\n\n## Tier 1: index search\n\n```bash\nlats search tideline\n```\n\n`lats search` calls the daemon's `windows.search` method. It matches the\nquery against each window's title, app name, session tag and, when\nscreen-text search is on, the text the window shows. It reads data the app\nalready holds, so it returns at once.\n\nResults are ranked. A title or session match scores 3, an app match 2, and a\nscreen-text match 1. Recent windows rank higher; add `--no-recency` to turn\nthat off.\n\nExpected: a list of windows with their window ids, apps and titles, best\nfirst. `No results for \"tideline\"` means no indexed field contains the word.\n\nNarrow the sources when you need to:\n\n```bash\nlats search tideline --quick      # titles, apps and sessions only\nlats search tideline --terminal   # terminal tabs only\n```\n\n## Tier 2: deep search\n\n```bash\nlats search tideline --deep\n```\n\n`--deep` (or `--all`) runs the index search, then inspects terminal windows\nthrough the daemon's `terminals.search` method: each tab's working\ndirectory, title, tmux session and running command. A terminal whose tabs\nsit in `~/dev/tideline` matches even when \"tideline\" is not in its window\ntitle. Each matching tab adds to the window's score, so a terminal with four\ntideline tabs outranks one with a single tab.\n\nDeep search reads tabs from iTerm2 and Terminal.app. It also reads Ghostty\ntabs when Automation access to Ghostty is already granted, and kitty tabs\nwhen `allow_remote_control` and `listen_on` are set in `kitty.conf`. Warp,\nAlacritty and other terminals are matched from their shell processes: to one\nwindow when the terminal has one titled window or a title names the shell's\ndirectory, and otherwise to the app as a whole. `lats place` does not act on\nan app-wide match alone.\n\nUse deep search when you look for a project by name and the project name\nlives in a directory path rather than a title. Use index search first; deep\nsearch costs a live inspection of every terminal.\n\n## Tier 3: screen text\n\nScreen-text search needs Screen Recording permission and **Search & OCR** on\nin Settings. It is off by default. With it on, the app reads text from\nvisible windows and keeps the latest results, which tier 1 then matches.\n\nSee what the app has read now:\n\n```bash\nlats scan            # the latest text per visible window\nlats scan --full     # everything, not a summary\nlats scan deep       # run a deep read of background windows now\n```\n\nSearch the stored history with full-text syntax:\n\n```bash\nlats scan search \"error OR failed\"\nlats scan history 4182     # every read of one window\nlats scan recent 10\n```\n\n`lats scan search` and `lats scan history` read a SQLite database at\n`~/.lattices/ocr.db`, which keeps three days of scans. The free build keeps\nonly the in-memory snapshot, so these two commands work in bundle builds\nonly. [Screen OCR and search](/lattices/docs/ocr) covers scan timing and\naccuracy modes.\n\n## Act on a result\n\nGo to the top match:\n\n```bash\nlats place tideline            # focus and tile bottom-right\nlats place tideline left\nlats place tideline left --dry-run --json\n```\n\n`lats place` runs a deep search, focuses the first result and places it.\nPass `--dry-run` to see which window it would pick before it moves.\n\nPipe window ids into other commands:\n\n```bash\nlats search tideline --wid\nlats window place $(lats search tideline --wid | head -1) right\n```\n\nGet structured results for a script:\n\n```bash\nlats search tideline --json\n```\n\nEach entry has `wid`, `app`, `title`, `score`, `reasons`, `tabs` and\n`ocrSnippet`.\n\nRaise a session's terminal by session name:\n\n```bash\nlats focus tideline-3f9a1c\n```\n\n## Choose a tier\n\n| You want | Use |\n|----------|-----|\n| A window by a word in its title or app | `lats search <word>` |\n| A terminal working in a project directory | `lats search <project> --deep` |\n| The window and tile it | `lats place <word> [position]` |\n| Windows showing an error message | `lats search <message>` with Search & OCR on |\n| What a window showed earlier today | `lats scan history <wid>` (bundle builds) |\n\nThe command bar (Control+Option+Space) searches the same index with the same\nscoring and shows screen-text matches as \"Screen Text\" results.\n\n## From the daemon\n\n```bash\nlats call windows.search '{\"query\":\"tideline\"}'\nlats call terminals.search '{\"query\":\"tideline\"}'\nlats call ocr.snapshot '{}'\n```\n\n`windows.search` returns window objects with `matchSource` and\n`ocrSnippet`. `terminals.search` returns one entry per terminal tab with its\n`windowId`. [Agent API](/lattices/docs/api) documents the parameters."
    },
    {
      "id": "lattices:guide-layers",
      "slug": "guide-layers",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Layers",
      "description": "What a workspace layer is, what happens when you switch, and how to build one from the keyboard, the CLI or workspace.json",
      "order": 1.4,
      "sourcePath": "docs/guide-layers.md",
      "url": "/lattices/docs/guide-layers",
      "rawUrl": "/lattices/docs/markdown/guide-layers.md",
      "markdownUrl": "/lattices/docs/guide-layers.md",
      "headings": [
        {
          "depth": 2,
          "text": "What a switch does",
          "anchor": "what-a-switch-does"
        },
        {
          "depth": 2,
          "text": "Switch with the keyboard",
          "anchor": "switch-with-the-keyboard"
        },
        {
          "depth": 2,
          "text": "Switch with the CLI",
          "anchor": "switch-with-the-cli"
        },
        {
          "depth": 2,
          "text": "Build a layer from what is on screen",
          "anchor": "build-a-layer-from-what-is-on-screen"
        },
        {
          "depth": 2,
          "text": "Declare a layer in workspace.json",
          "anchor": "declare-a-layer-in-workspace-json"
        },
        {
          "depth": 2,
          "text": "Leave layers",
          "anchor": "leave-layers"
        },
        {
          "depth": 2,
          "text": "From the daemon",
          "anchor": "from-the-daemon"
        },
        {
          "depth": 2,
          "text": "Related",
          "anchor": "related"
        }
      ],
      "tokensEstimate": 1167,
      "frontmatter": {
        "title": "Layers",
        "description": "What a workspace layer is, what happens when you switch, and how to build one from the keyboard, the CLI or workspace.json",
        "order": 1.4
      },
      "markdown": "---\ntitle: Layers\ndescription: What a workspace layer is, what happens when you switch, and how to build one from the keyboard, the CLI or workspace.json\norder: 1.4\n---\n\nA layer is a named set of windows that belong to one piece of work. When\nyou switch to a layer, Lattices puts away the windows the layer does not use,\nraises the ones it does, and lays them out. Layers replace most of what\nmacOS Spaces do, without moving windows between desktops.\n\nUp to eight layers sit on a pad of slots numbered 1 to 4 and 6 to 9. Slot 5\nmeans \"the layer I am on\".\n\nThe examples use an invented project, `tideline`.\n\n## What a switch does\n\nEvery route to a switch, keyboard, command bar, CLI or API, does the same\nthree things in order:\n\n1. **Put away.** Windows that the new layer does not use are hidden or\n   parked. Apps with no window in the layer are hidden. Apps with some\n   windows in the layer keep those and park the rest.\n2. **Raise.** The layer's windows come to the front, matched by app, title,\n   URL or project path.\n3. **Lay out.** A layer with a `layout` arranges its windows. Entries with\n   their own `tile` keep their place.\n\nNothing is closed. tmux sessions stay alive. A window belongs to at most one\nlayer. Choosing the layer you are already on gathers its windows back up.\n\n## Switch with the keyboard\n\n| Keys | Action |\n|------|--------|\n| Command+Option+1 to 4, 6 to 9 | Switch to the layer in that slot |\n| Command+Option+5 | Show which layer is active |\n| Control+Option+Shift+arrows | Move across the pad |\n| Command+Option+T | Add the front window to the active layer |\n| Command+Option+0 | Classic mode: every window back as if Lattices were not running |\n| Command+Option+Z | Undo the last layer edit |\n\nA switch shows a bezel with the pad. Settings > Shortcuts lists and changes\nthese bindings.\n\n## Switch with the CLI\n\n```bash\nlats layer                 # list layers and slots\nlats layer tideline        # by id or label\nlats layer 1               # by pad slot\nlats layer tideline --tile    # also move windows with a tile value\nlats layer tideline --launch  # start missing projects, then tile\nlats layer exit            # leave layers; restore parked windows\nlats layer classic         # the same as Command+Option+0\nlats layer undo\n```\n\n`lats layer exit`, `off`, `reveal` and `show-all` are the same action.\n\n## Build a layer from what is on screen\n\nThe quickest way to make a layer is to arrange windows by hand and save them:\n\n```bash\nlats layer snap tideline              # the windows on screen become a layer\nlats layer create tideline wid:4182 wid:4190\nlats layer add wid:4201 --to tideline\nlats layer remove wid:4201 --from tideline\nlats layer rename tideline \"Tideline web\"\nlats layer delete tideline\n```\n\nFind window ids with `lats windows` or `lats map`. A window added this way is\nsaved to `workspace.json` as its app and title, so it returns after a\nrestart. A window held by an entry that matches several windows, such as a\nbare app name, cannot be removed on its own; edit that entry instead.\n\n## Declare a layer in workspace.json\n\nLayers live in `~/.lattices/workspace.json`. A layer for tideline with its\nterminal, editor and browser:\n\n```json\n{\n  \"layers\": [\n    {\n      \"id\": \"tideline\",\n      \"label\": \"Tideline\",\n      \"layout\": \"auto\",\n      \"projects\": [\n        { \"path\": \"/Users/you/dev/tideline\" },\n        { \"app\": \"Cursor\", \"title\": \"tideline\" },\n        { \"app\": \"Google Chrome\", \"url\": \"http://localhost:3000\", \"launch\": \"open http://localhost:3000\" }\n      ]\n    }\n  ]\n}\n```\n\nEach entry names one of `path`, `group` or `app`:\n\n| Entry | Matches |\n|-------|---------|\n| `path` | The terminal window of that project's session |\n| `app` with optional `title` or `url` | Windows of that app whose title contains `title` or whose URL starts with `url` |\n| `group` | A tab group from the same file |\n\n`launch` is a shell command that opens the entry when no window matches and\nyou switch with `--launch`. `tile` gives an entry a fixed placement; without\nit the layer's `layout` decides.\n\n| `layout` | Arrangement |\n|----------|-------------|\n| `auto` | Lanes by app kind: terminals left, editors in the middle, browsers right. Columns on an ultrawide; a half-and-stack on a standard display. |\n| `columns` | Equal columns in entry order, up to four on an ultrawide and three otherwise. |\n| `master-stack` | The first window takes the left 62%; the rest stack on the right. |\n\nThe app reads the file when it changes. A layer's position on the pad is its\nposition in the `layers` array.\n\n## Leave layers\n\n```bash\nlats layer exit\n```\n\nLattices clears the active layer, restores parked windows and unhides apps.\nSaved layers stay in the file. Windows stay on the desktops they were on; a\nparked window on a desktop that is not showing is restored when you next\nvisit that desktop.\n\n## From the daemon\n\n```bash\nlats call layers.list '{}'\nlats call layers.switch '{\"name\":\"tideline\"}'\nlats call layers.switch '{\"name\":\"tideline\",\"mode\":\"launch\"}'\nlats call layers.exit '{}'\n```\n\n`layers.switch` takes `name` (id or label) or `index` (0-based position in\nthe list) and an optional `mode` of `tile` or `launch`. The daemon emits a\n`layer.switched` event after each switch.\n\n## Related\n\n- [Workspace layers and tab groups](/lattices/docs/layers): every field,\n  the put-away rules, rule-backed layers and the layer bar.\n- [Sessions](/lattices/docs/guide-sessions): the `path` entries point at\n  project sessions.\n",
      "content": "A layer is a named set of windows that belong to one piece of work. When\nyou switch to a layer, Lattices puts away the windows the layer does not use,\nraises the ones it does, and lays them out. Layers replace most of what\nmacOS Spaces do, without moving windows between desktops.\n\nUp to eight layers sit on a pad of slots numbered 1 to 4 and 6 to 9. Slot 5\nmeans \"the layer I am on\".\n\nThe examples use an invented project, `tideline`.\n\n## What a switch does\n\nEvery route to a switch, keyboard, command bar, CLI or API, does the same\nthree things in order:\n\n1. **Put away.** Windows that the new layer does not use are hidden or\n   parked. Apps with no window in the layer are hidden. Apps with some\n   windows in the layer keep those and park the rest.\n2. **Raise.** The layer's windows come to the front, matched by app, title,\n   URL or project path.\n3. **Lay out.** A layer with a `layout` arranges its windows. Entries with\n   their own `tile` keep their place.\n\nNothing is closed. tmux sessions stay alive. A window belongs to at most one\nlayer. Choosing the layer you are already on gathers its windows back up.\n\n## Switch with the keyboard\n\n| Keys | Action |\n|------|--------|\n| Command+Option+1 to 4, 6 to 9 | Switch to the layer in that slot |\n| Command+Option+5 | Show which layer is active |\n| Control+Option+Shift+arrows | Move across the pad |\n| Command+Option+T | Add the front window to the active layer |\n| Command+Option+0 | Classic mode: every window back as if Lattices were not running |\n| Command+Option+Z | Undo the last layer edit |\n\nA switch shows a bezel with the pad. Settings > Shortcuts lists and changes\nthese bindings.\n\n## Switch with the CLI\n\n```bash\nlats layer                 # list layers and slots\nlats layer tideline        # by id or label\nlats layer 1               # by pad slot\nlats layer tideline --tile    # also move windows with a tile value\nlats layer tideline --launch  # start missing projects, then tile\nlats layer exit            # leave layers; restore parked windows\nlats layer classic         # the same as Command+Option+0\nlats layer undo\n```\n\n`lats layer exit`, `off`, `reveal` and `show-all` are the same action.\n\n## Build a layer from what is on screen\n\nThe quickest way to make a layer is to arrange windows by hand and save them:\n\n```bash\nlats layer snap tideline              # the windows on screen become a layer\nlats layer create tideline wid:4182 wid:4190\nlats layer add wid:4201 --to tideline\nlats layer remove wid:4201 --from tideline\nlats layer rename tideline \"Tideline web\"\nlats layer delete tideline\n```\n\nFind window ids with `lats windows` or `lats map`. A window added this way is\nsaved to `workspace.json` as its app and title, so it returns after a\nrestart. A window held by an entry that matches several windows, such as a\nbare app name, cannot be removed on its own; edit that entry instead.\n\n## Declare a layer in workspace.json\n\nLayers live in `~/.lattices/workspace.json`. A layer for tideline with its\nterminal, editor and browser:\n\n```json\n{\n  \"layers\": [\n    {\n      \"id\": \"tideline\",\n      \"label\": \"Tideline\",\n      \"layout\": \"auto\",\n      \"projects\": [\n        { \"path\": \"/Users/you/dev/tideline\" },\n        { \"app\": \"Cursor\", \"title\": \"tideline\" },\n        { \"app\": \"Google Chrome\", \"url\": \"http://localhost:3000\", \"launch\": \"open http://localhost:3000\" }\n      ]\n    }\n  ]\n}\n```\n\nEach entry names one of `path`, `group` or `app`:\n\n| Entry | Matches |\n|-------|---------|\n| `path` | The terminal window of that project's session |\n| `app` with optional `title` or `url` | Windows of that app whose title contains `title` or whose URL starts with `url` |\n| `group` | A tab group from the same file |\n\n`launch` is a shell command that opens the entry when no window matches and\nyou switch with `--launch`. `tile` gives an entry a fixed placement; without\nit the layer's `layout` decides.\n\n| `layout` | Arrangement |\n|----------|-------------|\n| `auto` | Lanes by app kind: terminals left, editors in the middle, browsers right. Columns on an ultrawide; a half-and-stack on a standard display. |\n| `columns` | Equal columns in entry order, up to four on an ultrawide and three otherwise. |\n| `master-stack` | The first window takes the left 62%; the rest stack on the right. |\n\nThe app reads the file when it changes. A layer's position on the pad is its\nposition in the `layers` array.\n\n## Leave layers\n\n```bash\nlats layer exit\n```\n\nLattices clears the active layer, restores parked windows and unhides apps.\nSaved layers stay in the file. Windows stay on the desktops they were on; a\nparked window on a desktop that is not showing is restored when you next\nvisit that desktop.\n\n## From the daemon\n\n```bash\nlats call layers.list '{}'\nlats call layers.switch '{\"name\":\"tideline\"}'\nlats call layers.switch '{\"name\":\"tideline\",\"mode\":\"launch\"}'\nlats call layers.exit '{}'\n```\n\n`layers.switch` takes `name` (id or label) or `index` (0-based position in\nthe list) and an optional `mode` of `tile` or `launch`. The daemon emits a\n`layer.switched` event after each switch.\n\n## Related\n\n- [Workspace layers and tab groups](/lattices/docs/layers): every field,\n  the put-away rules, rule-backed layers and the layer bar.\n- [Sessions](/lattices/docs/guide-sessions): the `path` entries point at\n  project sessions."
    },
    {
      "id": "lattices:guide-machines",
      "slug": "guide-machines",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Working across machines",
      "description": "Run lats commands on another machine, lend your mouse and keyboard to it, and move windows between displays",
      "order": 1.5,
      "sourcePath": "docs/guide-machines.md",
      "url": "/lattices/docs/guide-machines",
      "rawUrl": "/lattices/docs/markdown/guide-machines.md",
      "markdownUrl": "/lattices/docs/guide-machines.md",
      "headings": [
        {
          "depth": 2,
          "text": "See what is around you",
          "anchor": "see-what-is-around-you"
        },
        {
          "depth": 2,
          "text": "Run commands on another machine",
          "anchor": "run-commands-on-another-machine"
        },
        {
          "depth": 3,
          "text": "1. Start the gateway on the host",
          "anchor": "1-start-the-gateway-on-the-host"
        },
        {
          "depth": 3,
          "text": "2. Register the host here",
          "anchor": "2-register-the-host-here"
        },
        {
          "depth": 3,
          "text": "3. Pair and approve",
          "anchor": "3-pair-and-approve"
        },
        {
          "depth": 3,
          "text": "4. Run commands",
          "anchor": "4-run-commands"
        },
        {
          "depth": 2,
          "text": "Lend your mouse and keyboard",
          "anchor": "lend-your-mouse-and-keyboard"
        },
        {
          "depth": 3,
          "text": "1. Allow visits on the host",
          "anchor": "1-allow-visits-on-the-host"
        },
        {
          "depth": 3,
          "text": "2. Pair the visit from here",
          "anchor": "2-pair-the-visit-from-here"
        },
        {
          "depth": 3,
          "text": "3. Say where the host sits",
          "anchor": "3-say-where-the-host-sits"
        },
        {
          "depth": 3,
          "text": "4. Arm crossing and visit",
          "anchor": "4-arm-crossing-and-visit"
        },
        {
          "depth": 2,
          "text": "Move windows between displays",
          "anchor": "move-windows-between-displays"
        },
        {
          "depth": 2,
          "text": "What stays local",
          "anchor": "what-stays-local"
        },
        {
          "depth": 2,
          "text": "Related",
          "anchor": "related"
        }
      ],
      "tokensEstimate": 1287,
      "frontmatter": {
        "title": "Working across machines",
        "description": "Run lats commands on another machine, lend your mouse and keyboard to it, and move windows between displays",
        "order": 1.5
      },
      "markdown": "---\ntitle: Working across machines\ndescription: Run lats commands on another machine, lend your mouse and keyboard to it, and move windows between displays\norder: 1.5\n---\n\nLattices reaches other machines in two separate ways. A **host** accepts\n`lats` commands from this Mac over the network. A **visit** lends this Mac's\nmouse and keyboard to another machine when you push the pointer past a\nscreen edge. The two are paired separately. This page sets up each one and\nthen covers the display commands that work with both.\n\nBoth ways go through the host's gateway, which binds only to the host's\nTailscale address. Install Tailscale on every machine first. Macs need the\nLattices app; Linux machines need the separate\n[Linux install](/lattices/docs/install-linux).\n\nThe examples use two Macs, `mini` (this one) and `air`, and a Linux box,\n`archie`.\n\n## See what is around you\n\n```bash\nlats machines\nlats machines --json\n```\n\nExpected: one row per machine with reachability, Lattices version, display\ncount, pairing state and placement. The local machine is always listed.\n\n## Run commands on another machine\n\n### 1. Start the gateway on the host\n\nOn `air`:\n\n```bash\nlats gateway start --tailnet\nlats gateway status\n```\n\nThe gateway runs until `lats gateway stop`. It does not install a login\nitem, so start it again after a reboot. It admits devices that belong to\nthe host's Tailscale user unless you pass `--allow-user` or `--allow-tag`.\n\n### 2. Register the host here\n\nOn `mini`:\n\n```bash\nlats hosts add air air.tailnet-name.ts.net\nlats hosts                # configured hosts and whether they answer\nlats hosts --discover\n```\n\nHosts are stored in `~/.lattices/hosts.json`.\n\n### 3. Pair and approve\n\nOn `mini`, request a scope:\n\n```bash\nlats --host air pair --read-only            # core read methods, no screen contents\nlats --host air pair --read-only --screen   # also screenshots, OCR, terminal text\nlats --host air pair                        # read, screen and act\nlats --host air pair --drive                # also desktop input and shell\nlats --host air pair --grant lattices:windows.list --grant lattices:windows.place\n```\n\nOn `air`, check the fingerprint and approve:\n\n```bash\nlats gateway clients\nlats gateway approve <client-id>\n```\n\nEach stored grant names one `product:method`. A later request for more\nmethods needs another approval on the host. To reduce a client's access,\nrevoke it and pair again:\n\n```bash\nlats gateway revoke <client-id>\n```\n\n### 4. Run commands\n\n```bash\nlats @air windows\nlats @air display list\nlats @air call windows.search '{\"query\":\"tideline\"}'\nlats --host air capture display 0     # screenshot saved on this Mac\n```\n\n`lats @<host>` forwards commands that wrap a daemon call. Commands that only\nmake sense locally say so. `@local` is this machine.\n\n## Lend your mouse and keyboard\n\nA visit lets you drive `archie` or `air` from `mini`'s keyboard. Your own\npointer parks at the edge; a labelled visitor cursor appears on the host.\n\n### 1. Allow visits on the host\n\nOn a Mac host:\n\n```bash\nlats visit host on\n```\n\nA Linux host accepts visits through `lattices-host` once its gateway runs.\n\n### 2. Pair the visit from here\n\nOn `mini`:\n\n```bash\nlats visit pair archie                 # uses the host's gateway address\nlats visit pair air air.tailnet-name.ts.net:9399 right\nlats visit status                      # paired hosts and this Mac's code\n```\n\nApprove the pairing on the host when asked. Visit pairing and host pairing\nare different stores; approving one does not grant the other.\n\n### 3. Say where the host sits\n\n```bash\nlats visit side archie left            # beside the leftmost display\nlats visit place archie -1920 0        # exact global coordinates\n```\n\nOr open **Workspace > Machines** in the app and drag the machine against the\ndisplay edge where the pointer should cross.\n\n### 4. Arm crossing and visit\n\n```bash\nlats visit on          # pushing past the edge starts a visit\nlats visit archie      # start a visit now, without crossing\n```\n\nExpected: the host draws a visitor cursor named after this Mac. Your keys\ntype on the host. Command is sent as Super on Linux.\n\nEnd a visit by moving back through the edge you came in by, by pressing\nControl+Option+Command+Home, or with:\n\n```bash\nlats visit end\nlats home              # end the visit and center the pointer on the main display\nlats visit off         # stop crossing at edges\n```\n\nA visit also ends on its own when either side stops answering for six\nseconds. Your own cursor never left, so nothing is stranded.\n\nForget a pairing:\n\n```bash\nlats visit forget archie\n```\n\n## Move windows between displays\n\nThese commands work on this Mac and, through `lats @<host>`, on a host.\nName a display by number or by any part of its name: `dell`, `u32`, `1`.\n\n```bash\nlats display list                        # displays and what each shows\nlats bring dell                          # every other display's windows onto the Dell\nlats bring --undo\nlats display gather u32 --to dell        # one display's windows, laid out as they were\nlats display restore u32\nlats main dell                           # make it main; reverts after 15 seconds\nlats main dell --keep\nlats visit arrangement keep              # accept a trial started in the app\nlats visit arrangement revert\n```\n\nA display plugged into another machine, through a monitor's second input for\nexample, can be marked so the pointer skips it:\n\n```bash\nlats elsewhere u32 arts-mini\nlats here u32\nlats visit screens\n```\n\n`lats display lend <display>` asks on the other screens where to gather a\ndisplay's windows, for an input switch the Mac cannot see.\n\nIn the app, **Workspace > Machines** does the same with a drawing of the\ndisplays. Moving a local display there creates a draft: **Apply** starts a\n15-second trial, **Keep** accepts it and **Revert** restores the previous\narrangement. Opening Machines does not arm a visit.\n\n## What stays local\n\nThe gateway never forwards raw input events, Action drive leases, Speech\nplayback reservations, voice input or the VNC listener. Remote screenshots\nneed the `--screen` scope. See\n[Private IPC and the gateway](/lattices/docs/concepts#private-ipc-and-the-gateway)\nfor the full list.\n\n## Related\n\n- [Visiting cursor](/lattices/docs/visit): the visit protocol and host-side\n  behaviour.\n- [Install on Linux](/lattices/docs/install-linux): the Hyprland host.\n",
      "content": "Lattices reaches other machines in two separate ways. A **host** accepts\n`lats` commands from this Mac over the network. A **visit** lends this Mac's\nmouse and keyboard to another machine when you push the pointer past a\nscreen edge. The two are paired separately. This page sets up each one and\nthen covers the display commands that work with both.\n\nBoth ways go through the host's gateway, which binds only to the host's\nTailscale address. Install Tailscale on every machine first. Macs need the\nLattices app; Linux machines need the separate\n[Linux install](/lattices/docs/install-linux).\n\nThe examples use two Macs, `mini` (this one) and `air`, and a Linux box,\n`archie`.\n\n## See what is around you\n\n```bash\nlats machines\nlats machines --json\n```\n\nExpected: one row per machine with reachability, Lattices version, display\ncount, pairing state and placement. The local machine is always listed.\n\n## Run commands on another machine\n\n### 1. Start the gateway on the host\n\nOn `air`:\n\n```bash\nlats gateway start --tailnet\nlats gateway status\n```\n\nThe gateway runs until `lats gateway stop`. It does not install a login\nitem, so start it again after a reboot. It admits devices that belong to\nthe host's Tailscale user unless you pass `--allow-user` or `--allow-tag`.\n\n### 2. Register the host here\n\nOn `mini`:\n\n```bash\nlats hosts add air air.tailnet-name.ts.net\nlats hosts                # configured hosts and whether they answer\nlats hosts --discover\n```\n\nHosts are stored in `~/.lattices/hosts.json`.\n\n### 3. Pair and approve\n\nOn `mini`, request a scope:\n\n```bash\nlats --host air pair --read-only            # core read methods, no screen contents\nlats --host air pair --read-only --screen   # also screenshots, OCR, terminal text\nlats --host air pair                        # read, screen and act\nlats --host air pair --drive                # also desktop input and shell\nlats --host air pair --grant lattices:windows.list --grant lattices:windows.place\n```\n\nOn `air`, check the fingerprint and approve:\n\n```bash\nlats gateway clients\nlats gateway approve <client-id>\n```\n\nEach stored grant names one `product:method`. A later request for more\nmethods needs another approval on the host. To reduce a client's access,\nrevoke it and pair again:\n\n```bash\nlats gateway revoke <client-id>\n```\n\n### 4. Run commands\n\n```bash\nlats @air windows\nlats @air display list\nlats @air call windows.search '{\"query\":\"tideline\"}'\nlats --host air capture display 0     # screenshot saved on this Mac\n```\n\n`lats @<host>` forwards commands that wrap a daemon call. Commands that only\nmake sense locally say so. `@local` is this machine.\n\n## Lend your mouse and keyboard\n\nA visit lets you drive `archie` or `air` from `mini`'s keyboard. Your own\npointer parks at the edge; a labelled visitor cursor appears on the host.\n\n### 1. Allow visits on the host\n\nOn a Mac host:\n\n```bash\nlats visit host on\n```\n\nA Linux host accepts visits through `lattices-host` once its gateway runs.\n\n### 2. Pair the visit from here\n\nOn `mini`:\n\n```bash\nlats visit pair archie                 # uses the host's gateway address\nlats visit pair air air.tailnet-name.ts.net:9399 right\nlats visit status                      # paired hosts and this Mac's code\n```\n\nApprove the pairing on the host when asked. Visit pairing and host pairing\nare different stores; approving one does not grant the other.\n\n### 3. Say where the host sits\n\n```bash\nlats visit side archie left            # beside the leftmost display\nlats visit place archie -1920 0        # exact global coordinates\n```\n\nOr open **Workspace > Machines** in the app and drag the machine against the\ndisplay edge where the pointer should cross.\n\n### 4. Arm crossing and visit\n\n```bash\nlats visit on          # pushing past the edge starts a visit\nlats visit archie      # start a visit now, without crossing\n```\n\nExpected: the host draws a visitor cursor named after this Mac. Your keys\ntype on the host. Command is sent as Super on Linux.\n\nEnd a visit by moving back through the edge you came in by, by pressing\nControl+Option+Command+Home, or with:\n\n```bash\nlats visit end\nlats home              # end the visit and center the pointer on the main display\nlats visit off         # stop crossing at edges\n```\n\nA visit also ends on its own when either side stops answering for six\nseconds. Your own cursor never left, so nothing is stranded.\n\nForget a pairing:\n\n```bash\nlats visit forget archie\n```\n\n## Move windows between displays\n\nThese commands work on this Mac and, through `lats @<host>`, on a host.\nName a display by number or by any part of its name: `dell`, `u32`, `1`.\n\n```bash\nlats display list                        # displays and what each shows\nlats bring dell                          # every other display's windows onto the Dell\nlats bring --undo\nlats display gather u32 --to dell        # one display's windows, laid out as they were\nlats display restore u32\nlats main dell                           # make it main; reverts after 15 seconds\nlats main dell --keep\nlats visit arrangement keep              # accept a trial started in the app\nlats visit arrangement revert\n```\n\nA display plugged into another machine, through a monitor's second input for\nexample, can be marked so the pointer skips it:\n\n```bash\nlats elsewhere u32 arts-mini\nlats here u32\nlats visit screens\n```\n\n`lats display lend <display>` asks on the other screens where to gather a\ndisplay's windows, for an input switch the Mac cannot see.\n\nIn the app, **Workspace > Machines** does the same with a drawing of the\ndisplays. Moving a local display there creates a draft: **Apply** starts a\n15-second trial, **Keep** accepts it and **Revert** restores the previous\narrangement. Opening Machines does not arm a visit.\n\n## What stays local\n\nThe gateway never forwards raw input events, Action drive leases, Speech\nplayback reservations, voice input or the VNC listener. Remote screenshots\nneed the `--screen` scope. See\n[Private IPC and the gateway](/lattices/docs/concepts#private-ipc-and-the-gateway)\nfor the full list.\n\n## Related\n\n- [Visiting cursor](/lattices/docs/visit): the visit protocol and host-side\n  behaviour.\n- [Install on Linux](/lattices/docs/install-linux): the Hyprland host."
    },
    {
      "id": "lattices:guide-agents",
      "slug": "guide-agents",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Lattices for AI agents",
      "description": "Give Claude Code, Codex or any agent the desktop through skills, the lats CLI, the MCP server and the daemon API",
      "order": 1.6,
      "sourcePath": "docs/guide-agents.md",
      "url": "/lattices/docs/guide-agents",
      "rawUrl": "/lattices/docs/markdown/guide-agents.md",
      "markdownUrl": "/lattices/docs/guide-agents.md",
      "headings": [
        {
          "depth": 2,
          "text": "Pick a surface",
          "anchor": "pick-a-surface"
        },
        {
          "depth": 2,
          "text": "1. Install the skills",
          "anchor": "1-install-the-skills"
        },
        {
          "depth": 2,
          "text": "2. First checks from the CLI",
          "anchor": "2-first-checks-from-the-cli"
        },
        {
          "depth": 2,
          "text": "3. Register the MCP server",
          "anchor": "3-register-the-mcp-server"
        },
        {
          "depth": 2,
          "text": "4. Call the daemon directly",
          "anchor": "4-call-the-daemon-directly"
        },
        {
          "depth": 2,
          "text": "Rules for a well-behaved agent",
          "anchor": "rules-for-a-well-behaved-agent"
        },
        {
          "depth": 2,
          "text": "Related",
          "anchor": "related"
        }
      ],
      "tokensEstimate": 1330,
      "frontmatter": {
        "title": "Lattices for AI agents",
        "description": "Give Claude Code, Codex or any agent the desktop through skills, the lats CLI, the MCP server and the daemon API",
        "order": 1.6
      },
      "markdown": "---\ntitle: Lattices for AI agents\ndescription: Give Claude Code, Codex or any agent the desktop through skills, the lats CLI, the MCP server and the daemon API\norder: 1.6\n---\n\nAn agent drives Lattices through the same model you do: windows, sessions,\nlayers and screen text. This page sets up each surface, in the order most\npeople need them, and states the rules an agent should follow before it\nmoves anything.\n\nEverything here needs the menu bar app running. Agents on another machine\ngo through the gateway; see\n[Working across machines](/lattices/docs/guide-machines).\n\n## Pick a surface\n\n| Surface | Use it when | Setup |\n|---------|-------------|-------|\n| Skills | The agent runs in a harness that loads skills, such as Claude Code | `npx skills add lattices-dev/lattices --all --global --yes` |\n| CLI | The agent has a shell. Covers most tasks. | `lats` on `PATH` |\n| MCP | The harness speaks MCP and you want window, layer, browser or remote-host tools | `lats mcp` registered as a server |\n| Daemon API | A script or a typed client needs receipts and events | `lats call`, or `daemonCall` from `@lattices/lattices` |\n\nPrefer the CLI. Use `lats call` when no dedicated command exists. Use the\nNode client only in scripts.\n\n## 1. Install the skills\n\n```bash\nnpx skills add lattices-dev/lattices --list\nnpx skills add lattices-dev/lattices --all --global --yes\n```\n\nThe catalog has four skills. `lattices` covers tiling, sessions, search and\nlayers. `action`, `voice` and `blink` belong to the other products and work\nonly when those apps are installed. Install one at a time with\n`--skill lattices`.\n\nEach skill tells the agent which commands to run and when to stop. The\n`lattices` skill starts with `lats daemon status` and refuses to invent\nwindow ids, session names or layer names.\n\n## 2. First checks from the CLI\n\nBefore any mutation, read the desktop:\n\n```bash\nlats daemon status           # is the app up and which version\nlats doctor                  # permissions, capabilities, events\nlats windows --json          # every visible window with its wid\nlats map --json              # the same, per display, with coordinates\nlats call desktop.snapshot   # the current Space, with window ids\nlats call api.schema         # every daemon method and its parameters\n```\n\nExpected: `lats daemon status` prints a version. If it prints `Daemon not\nrunning`, tell the user to run `lats app`. Do not try to start the app from\nan agent without asking.\n\nThen act with the commands from the other guides:\n\n```bash\nlats search tideline --deep --json\nlats place tideline left --dry-run --json\nlats window place 4182 right\nlats layer tideline --launch\nlats call windows.place '{\"wid\":4182,\"placement\":\"left\",\"dryRun\":true}'\n```\n\n`--dry-run` and `dryRun` return the plan without moving a window. Every\nmutating call returns a receipt that names the window it moved and the frame\nit ended in.\n\n## 3. Register the MCP server\n\nThe `lattices` package ships one MCP server. Agent config names the binary,\nnever a path:\n\n```json\n{ \"mcpServers\": { \"lattices\": { \"command\": \"lattices\", \"args\": [\"mcp\"] } } }\n```\n\nPrint the snippet for a harness:\n\n```bash\nlats mcp --print-config claude\nlats mcp --print-config codex\nlats mcp --print-config kimi\n```\n\nFor Claude Code:\n\n```bash\nclaude mcp add lattices -s user -- lattices mcp\n```\n\nFor Codex, in `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.lattices]\ncommand = \"lattices\"\nargs = [\"mcp\"]\n```\n\nCheck what the server offers without starting a session:\n\n```bash\nlats mcp --list\n```\n\nThe server has three toolsets:\n\n| Toolset | Tools | Does the work |\n|---------|-------|---------------|\n| `windows` | `windows_search`, `windows_place`, `windows_focus`, `layers_list`, `layers_switch`, `history_undo` | The Lattices daemon on this Mac |\n| `browser` | `browser_open`, `browser_tabs`, `browser_snapshot`, `browser_click`, `browser_fill`, `browser_resize`, `browser_screenshot`, `browser_console`, `browser_close`, `browser_profiles`, `browser_use_profile`, `browser_profile_info`, `browser_import_cookies`, `browser_companion_status` | Action Browser code in the package, driving a Chrome that Action owns |\n| `hosts` | `hosts_list`, `host_describe`, `host_windows`, `host_screenshot`, `host_read`, `host_place`, `host_focus`, `host_act`, `host_call` | The Lattices daemon here, or a paired host |\n\nServe a subset with `lats mcp --toolsets browser`. In the harness, `/mcp`\nshould list `lattices` as connected.\n\nWindow search, placement, focus, layers and undo are MCP tools in the\n`windows` toolset. Search with `windows_search` first and set `deep: true`\nonly when it misses. `windows_place` returns a `receiptId`; `history_undo`\nwith that id puts the window back. Sessions are not MCP tools yet; use the\nCLI or the daemon API. Action.app has its own MCP server for\nnative observe and act, started with `lats action mcp`; it is separate from\n`lats mcp`.\n\n## 4. Call the daemon directly\n\nThe daemon listens on a Unix socket, `~/.lattices/run/lattices.sock`, and\nrequires the token stored beside it in `lattices.token`. `lats call` and the\nNode client find both for you:\n\n```bash\nlats call windows.list '{}'\nlats call layers.switch '{\"name\":\"tideline\"}'\n```\n\n```js\nimport { daemonCall, isDaemonRunning } from '@lattices/lattices'\n\nif (await isDaemonRunning()) {\n  const windows = await daemonCall('windows.list', {})\n  await daemonCall('windows.place', { wid: windows[0].wid, placement: 'left' })\n}\n```\n\nA raw WebSocket client must read the token file, connect to the socket,\nsend `Host: localhost` and the `x-lattices-token` header on the upgrade. The\nsocket accepts only this user. There is no TCP port unless you set\n`LATTICES_ALLOW_LOOPBACK_TCP=1`, and the token is still required then.\n\nMethods follow `<domain>.<verb>`: `windows.search`, `windows.place`,\n`sessions.launch`, `layers.switch`, `ocr.snapshot`, `terminals.search`,\n`capture.screenshotWindow`. `lats call api.schema` lists them all with\nparameters. [Agent API](/lattices/docs/api) is the written reference.\n\n## Rules for a well-behaved agent\n\n- Read before you move. Take a snapshot, resolve the target, then place.\n- Target by `wid` or session name. Never guess an id.\n- Use `dryRun` when the target is ambiguous.\n- Move one window at a time unless the user asked for a layout.\n- Do not switch layers or Spaces to look around. Use `lats windows --json`\n  and `lats map --json`, which read without moving.\n- Screenshots are audited. Each `lats capture` and every `capture.*` call\n  draws a short outline on screen and appends a line to\n  `~/.lattices/audit/captures.jsonl`. The CLI names Claude Code, Codex,\n  opencode and Cursor from their environment. Set `LATTICES_AGENT` to name\n  any other agent.\n\n## Related\n\n- [Agent guide](/lattices/docs/agents): the canonical action contracts and\n  receipts.\n- [MCP](/lattices/docs/mcp): why the server is a subcommand, and how the\n  toolsets are built.\n- [Finding windows](/lattices/docs/guide-search) and\n  [Tiling and placement](/lattices/docs/guide-tiling): the commands an\n  agent runs most.\n",
      "content": "An agent drives Lattices through the same model you do: windows, sessions,\nlayers and screen text. This page sets up each surface, in the order most\npeople need them, and states the rules an agent should follow before it\nmoves anything.\n\nEverything here needs the menu bar app running. Agents on another machine\ngo through the gateway; see\n[Working across machines](/lattices/docs/guide-machines).\n\n## Pick a surface\n\n| Surface | Use it when | Setup |\n|---------|-------------|-------|\n| Skills | The agent runs in a harness that loads skills, such as Claude Code | `npx skills add lattices-dev/lattices --all --global --yes` |\n| CLI | The agent has a shell. Covers most tasks. | `lats` on `PATH` |\n| MCP | The harness speaks MCP and you want window, layer, browser or remote-host tools | `lats mcp` registered as a server |\n| Daemon API | A script or a typed client needs receipts and events | `lats call`, or `daemonCall` from `@lattices/lattices` |\n\nPrefer the CLI. Use `lats call` when no dedicated command exists. Use the\nNode client only in scripts.\n\n## 1. Install the skills\n\n```bash\nnpx skills add lattices-dev/lattices --list\nnpx skills add lattices-dev/lattices --all --global --yes\n```\n\nThe catalog has four skills. `lattices` covers tiling, sessions, search and\nlayers. `action`, `voice` and `blink` belong to the other products and work\nonly when those apps are installed. Install one at a time with\n`--skill lattices`.\n\nEach skill tells the agent which commands to run and when to stop. The\n`lattices` skill starts with `lats daemon status` and refuses to invent\nwindow ids, session names or layer names.\n\n## 2. First checks from the CLI\n\nBefore any mutation, read the desktop:\n\n```bash\nlats daemon status           # is the app up and which version\nlats doctor                  # permissions, capabilities, events\nlats windows --json          # every visible window with its wid\nlats map --json              # the same, per display, with coordinates\nlats call desktop.snapshot   # the current Space, with window ids\nlats call api.schema         # every daemon method and its parameters\n```\n\nExpected: `lats daemon status` prints a version. If it prints `Daemon not\nrunning`, tell the user to run `lats app`. Do not try to start the app from\nan agent without asking.\n\nThen act with the commands from the other guides:\n\n```bash\nlats search tideline --deep --json\nlats place tideline left --dry-run --json\nlats window place 4182 right\nlats layer tideline --launch\nlats call windows.place '{\"wid\":4182,\"placement\":\"left\",\"dryRun\":true}'\n```\n\n`--dry-run` and `dryRun` return the plan without moving a window. Every\nmutating call returns a receipt that names the window it moved and the frame\nit ended in.\n\n## 3. Register the MCP server\n\nThe `lattices` package ships one MCP server. Agent config names the binary,\nnever a path:\n\n```json\n{ \"mcpServers\": { \"lattices\": { \"command\": \"lattices\", \"args\": [\"mcp\"] } } }\n```\n\nPrint the snippet for a harness:\n\n```bash\nlats mcp --print-config claude\nlats mcp --print-config codex\nlats mcp --print-config kimi\n```\n\nFor Claude Code:\n\n```bash\nclaude mcp add lattices -s user -- lattices mcp\n```\n\nFor Codex, in `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.lattices]\ncommand = \"lattices\"\nargs = [\"mcp\"]\n```\n\nCheck what the server offers without starting a session:\n\n```bash\nlats mcp --list\n```\n\nThe server has three toolsets:\n\n| Toolset | Tools | Does the work |\n|---------|-------|---------------|\n| `windows` | `windows_search`, `windows_place`, `windows_focus`, `layers_list`, `layers_switch`, `history_undo` | The Lattices daemon on this Mac |\n| `browser` | `browser_open`, `browser_tabs`, `browser_snapshot`, `browser_click`, `browser_fill`, `browser_resize`, `browser_screenshot`, `browser_console`, `browser_close`, `browser_profiles`, `browser_use_profile`, `browser_profile_info`, `browser_import_cookies`, `browser_companion_status` | Action Browser code in the package, driving a Chrome that Action owns |\n| `hosts` | `hosts_list`, `host_describe`, `host_windows`, `host_screenshot`, `host_read`, `host_place`, `host_focus`, `host_act`, `host_call` | The Lattices daemon here, or a paired host |\n\nServe a subset with `lats mcp --toolsets browser`. In the harness, `/mcp`\nshould list `lattices` as connected.\n\nWindow search, placement, focus, layers and undo are MCP tools in the\n`windows` toolset. Search with `windows_search` first and set `deep: true`\nonly when it misses. `windows_place` returns a `receiptId`; `history_undo`\nwith that id puts the window back. Sessions are not MCP tools yet; use the\nCLI or the daemon API. Action.app has its own MCP server for\nnative observe and act, started with `lats action mcp`; it is separate from\n`lats mcp`.\n\n## 4. Call the daemon directly\n\nThe daemon listens on a Unix socket, `~/.lattices/run/lattices.sock`, and\nrequires the token stored beside it in `lattices.token`. `lats call` and the\nNode client find both for you:\n\n```bash\nlats call windows.list '{}'\nlats call layers.switch '{\"name\":\"tideline\"}'\n```\n\n```js\nimport { daemonCall, isDaemonRunning } from '@lattices/lattices'\n\nif (await isDaemonRunning()) {\n  const windows = await daemonCall('windows.list', {})\n  await daemonCall('windows.place', { wid: windows[0].wid, placement: 'left' })\n}\n```\n\nA raw WebSocket client must read the token file, connect to the socket,\nsend `Host: localhost` and the `x-lattices-token` header on the upgrade. The\nsocket accepts only this user. There is no TCP port unless you set\n`LATTICES_ALLOW_LOOPBACK_TCP=1`, and the token is still required then.\n\nMethods follow `<domain>.<verb>`: `windows.search`, `windows.place`,\n`sessions.launch`, `layers.switch`, `ocr.snapshot`, `terminals.search`,\n`capture.screenshotWindow`. `lats call api.schema` lists them all with\nparameters. [Agent API](/lattices/docs/api) is the written reference.\n\n## Rules for a well-behaved agent\n\n- Read before you move. Take a snapshot, resolve the target, then place.\n- Target by `wid` or session name. Never guess an id.\n- Use `dryRun` when the target is ambiguous.\n- Move one window at a time unless the user asked for a layout.\n- Do not switch layers or Spaces to look around. Use `lats windows --json`\n  and `lats map --json`, which read without moving.\n- Screenshots are audited. Each `lats capture` and every `capture.*` call\n  draws a short outline on screen and appends a line to\n  `~/.lattices/audit/captures.jsonl`. The CLI names Claude Code, Codex,\n  opencode and Cursor from their environment. Set `LATTICES_AGENT` to name\n  any other agent.\n\n## Related\n\n- [Agent guide](/lattices/docs/agents): the canonical action contracts and\n  receipts.\n- [MCP](/lattices/docs/mcp): why the server is a subcommand, and how the\n  toolsets are built.\n- [Finding windows](/lattices/docs/guide-search) and\n  [Tiling and placement](/lattices/docs/guide-tiling): the commands an\n  agent runs most."
    },
    {
      "id": "lattices:app",
      "slug": "app",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Menu Bar App",
      "description": "Command palette, window tiling, and session management",
      "order": 3,
      "sourcePath": "docs/app.md",
      "url": "/lattices/docs/app",
      "rawUrl": "/lattices/docs/markdown/app.md",
      "markdownUrl": "/lattices/docs/app.md",
      "headings": [
        {
          "depth": 2,
          "text": "Installation",
          "anchor": "installation"
        },
        {
          "depth": 2,
          "text": "Updating",
          "anchor": "updating"
        },
        {
          "depth": 2,
          "text": "Command palette",
          "anchor": "command-palette"
        },
        {
          "depth": 3,
          "text": "Project commands",
          "anchor": "project-commands"
        },
        {
          "depth": 3,
          "text": "Window commands",
          "anchor": "window-commands"
        },
        {
          "depth": 3,
          "text": "Tab group commands",
          "anchor": "tab-group-commands"
        },
        {
          "depth": 3,
          "text": "Layer commands",
          "anchor": "layer-commands"
        },
        {
          "depth": 3,
          "text": "App commands",
          "anchor": "app-commands"
        },
        {
          "depth": 2,
          "text": "Overlay actors and HUDs",
          "anchor": "overlay-actors-and-huds"
        },
        {
          "depth": 2,
          "text": "Project discovery",
          "anchor": "project-discovery"
        },
        {
          "depth": 2,
          "text": "Session management",
          "anchor": "session-management"
        },
        {
          "depth": 2,
          "text": "Window tiling",
          "anchor": "window-tiling"
        },
        {
          "depth": 3,
          "text": "Tile positions (app)",
          "anchor": "tile-positions-app"
        },
        {
          "depth": 2,
          "text": "Moving windows between displays and desktops",
          "anchor": "moving-windows-between-displays-and-desktops"
        },
        {
          "depth": 2,
          "text": "Space navigation",
          "anchor": "space-navigation"
        },
        {
          "depth": 3,
          "text": "Instant space switching",
          "anchor": "instant-space-switching"
        },
        {
          "depth": 2,
          "text": "Voice commands",
          "anchor": "voice-commands"
        },
        {
          "depth": 2,
          "text": "Settings",
          "anchor": "settings"
        },
        {
          "depth": 3,
          "text": "General",
          "anchor": "general"
        },
        {
          "depth": 3,
          "text": "Keyboard Remaps",
          "anchor": "keyboard-remaps"
        },
        {
          "depth": 3,
          "text": "Companion",
          "anchor": "companion"
        },
        {
          "depth": 3,
          "text": "AI",
          "anchor": "ai"
        },
        {
          "depth": 3,
          "text": "Shortcuts",
          "anchor": "shortcuts"
        },
        {
          "depth": 3,
          "text": "In-place window tools",
          "anchor": "in-place-window-tools"
        },
        {
          "depth": 3,
          "text": "Agent screenshots",
          "anchor": "agent-screenshots"
        },
        {
          "depth": 3,
          "text": "Docs",
          "anchor": "docs"
        },
        {
          "depth": 2,
          "text": "Supported terminals",
          "anchor": "supported-terminals"
        },
        {
          "depth": 2,
          "text": "Screen OCR",
          "anchor": "screen-ocr"
        },
        {
          "depth": 3,
          "text": "How it works",
          "anchor": "how-it-works"
        },
        {
          "depth": 3,
          "text": "Desktop Inventory integration",
          "anchor": "desktop-inventory-integration"
        },
        {
          "depth": 3,
          "text": "API access",
          "anchor": "api-access"
        },
        {
          "depth": 3,
          "text": "Requirements",
          "anchor": "requirements"
        },
        {
          "depth": 2,
          "text": "Local services",
          "anchor": "local-services"
        },
        {
          "depth": 2,
          "text": "Agent API server",
          "anchor": "agent-api-server"
        },
        {
          "depth": 3,
          "text": "Checking status",
          "anchor": "checking-status"
        },
        {
          "depth": 3,
          "text": "What it provides",
          "anchor": "what-it-provides"
        },
        {
          "depth": 3,
          "text": "Security",
          "anchor": "security"
        },
        {
          "depth": 2,
          "text": "Diagnostics",
          "anchor": "diagnostics"
        }
      ],
      "tokensEstimate": 5536,
      "frontmatter": {
        "title": "Menu Bar App",
        "description": "Command palette, window tiling, and session management",
        "order": 3
      },
      "markdown": "---\ntitle: Menu Bar App\ndescription: Command palette, window tiling, and session management\norder: 3\n---\n\nThe lattices menu bar app sits in your menu bar and controls your\nworkspace from there.\n\n## Installation\n\n```bash\nlats app              # Build (or download) and launch\nlats app install      # Register launch-at-login and launch now\nlats app update       # Swap in the latest app release only\nlats app build        # Rebuild from source\nlats app restart      # Quit, rebuild, relaunch\nlats app quit         # Stop the app\n```\n\nThe first run builds from source if Swift is available, otherwise\ndownloads a pre-built binary from GitHub releases.\n\nUse `lats app install` when you want the companion to start now and\nopen automatically at login. It installs a user LaunchAgent in\n`~/Library/LaunchAgents`; inspect it with `lats app login status`\nand remove it with `lats app login disable`.\n\n## Updating\n\n```bash\nlats update           # Update the CLI and app together\n```\n\n`lats update` reinstalls `@lattices/lattices` with the package manager\nthat owns your global install (npm, bun, or pnpm). It re-registers\nlaunch-at-login so the LaunchAgent points at the new install, and it\nrelaunches the app if it was running. Pass `--force` to reinstall when\nyou already have the latest version.\n\n## Command palette\n\nPress **Control+Option+Space** from anywhere to open the command palette.\nIt's a searchable list of every action the app can perform, with\nfuzzy matching on titles and subtitles.\n\n### Project commands\n\n| Command                       | Description                              |\n|-------------------------------|------------------------------------------|\n| Launch *project*              | Create a new session and open terminal   |\n| Attach *project*              | Focus or open the running session        |\n| Sync *project*                | Reconcile session to declared config     |\n| Restart *pane* in *project*   | Kill and re-run a specific pane's command |\n\n### Window commands\n\nAvailable for running sessions:\n\n| Command                       | Description                              |\n|-------------------------------|------------------------------------------|\n| Go to *project*               | Focus the terminal window (switches Spaces if needed) |\n| Tile *project* Left           | Snap window to left half                 |\n| Tile *project* Right          | Snap window to right half                |\n| Maximize *project*            | Expand window to fill screen             |\n| Detach *project*              | Disconnect clients, keep session alive   |\n| Kill *project*                | Terminate the tmux session               |\n\n### Tab group commands\n\nAvailable when `groups` are configured in `~/.lattices/workspace.json`\n(see [Tab Groups](/docs/layers#tab-groups)):\n\n| Command                     | Description                              |\n|-----------------------------|------------------------------------------|\n| Launch *group*              | Start the group session (all tabs)       |\n| Attach *group*              | Focus the running group session          |\n| *Group*: *Tab*              | Switch to a specific tab within a group  |\n| Kill *group* Group          | Terminate the group session              |\n\n### Layer commands\n\nAvailable when `layers` are configured in `~/.lattices/workspace.json`\n(see [Layers](/docs/layers#layers)):\n\n| Command                     | Description                              |\n|-----------------------------|------------------------------------------|\n| Switch to Layer: *label*    | Focus and tile the layer's project windows |\n| Show All Windows            | Put back what layer switches parked or hid |\n\n### App commands\n\n| Command           | Description                              |\n|-------------------|------------------------------------------|\n| Settings          | Open preferences (terminal, scan root)   |\n| Update Lattices   | Download the latest release and relaunch |\n| Diagnostics       | View logs and debug info                 |\n| Refresh Projects  | Re-scan .lattices.json configs, reload workspace.json |\n| Quit Lattices      | Exit the menu bar app                    |\n\n## Overlay actors and HUDs\n\nPersistent overlay actors can be hidden or restored with **Hyper+B**. Apps can\npublish a static hover dashboard by exposing:\n\n```txt\n.lattices/hud/manifest.json\n```\n\nRegister and publish one:\n\n```bash\nlats hud register .lattices/hud/manifest.json --publish\n```\n\nThe manifest points to a local `index.html`, optional icon, app activation\ntarget, HUD dimensions, and optional app-owned `sources` metadata for logs or\nstate files. Lattices hosts the actor and loads the dashboard through a\ntransparent `WKWebView`; the app owns the renderer and writes its own logs in\nthe places it already uses.\n\n## Project discovery\n\nThe app scans a configurable root directory (up to 3 levels deep)\nfor `.lattices.json` files. It skips `.git/` and `node_modules/`.\n\nAuto-detection for the scan root checks these paths in order:\n`~/dev`, `~/Developer`, `~/projects`, `~/src`.\n\nFor each project found, the app reads:\n- Pane names and commands from `.lattices.json`\n- Dev command and package manager from `package.json`\n- Running status by checking `tmux has-session`\n\n## Session management\n\nThe app calls the lattices CLI for session operations. Launch runs\n`lats start` in the project directory, Sync runs `lats sync` to\nreconcile panes, and Restart runs `lats restart <pane>` to kill\nand re-run a pane's process. Detach and Kill call `tmux detach-client`\nand `tmux kill-session` directly.\n\n## Window tiling\n\nThe app can tile terminal windows to preset screen positions via\nthe command palette. It finds windows by their `[lattices:session-name]`\ntitle tag.\n\nFor Terminal.app and iTerm2, tiling uses AppleScript to set window\nbounds by matching the title tag. For other terminals, it tiles the\nfrontmost window.\n\n### Tile positions (app)\n\n| Position     | Area                            |\n|--------------|---------------------------------|\n| Left         | Left half                       |\n| Right        | Right half                      |\n| Top Left     | Top-left quarter                |\n| Top Right    | Top-right quarter               |\n| Bottom Left  | Bottom-left quarter             |\n| Bottom Right | Bottom-right quarter            |\n| Maximize     | Full visible screen             |\n| Left Third   | Left third                      |\n| Center Third | Center third                    |\n| Right Third  | Right third                     |\n| Center       | 80% width, 80% height, centered (20% margin all sides) |\n\n## Moving windows between displays and desktops\n\nIn **Overview**, select a window and open **Move** in the floating controls.\nEach destination includes the display name and desktop number, such as\n**DELL S3422DWG · Desktop 2** or **Action Agent Layer · Desktop 2**.\n**Bring Here** selects the current desktop on the display that contains the\nOverview window.\n\nDrag a window shape onto another display map to move it to that display's\ncurrent desktop. Drop it onto a desktop thumbnail to choose that exact\ndesktop. The destination highlight identifies the drop target. Selecting a\nwindow shows its controls over the canvas; the display maps and arrangement\ntray keep their positions.\n\nDesktop Inventory and Studio also offer window movement in their context\nmenus:\n\n- **Bring Here** moves the selection to the UI's display and current desktop.\n- **Move to Desktop** lists each display and its desktops.\n- **Move to Next Monitor** cycles through displays and preserves normalized\n  window position and size.\n- **Move to Monitor** selects a display's current desktop.\n- **Move & Place** selects a display and a placement slot for one window.\n\nA right-click on a member of a multi-selection targets the whole selection.\nEach window keeps its own normalized frame. Move & Place is available for a\nsingle window. Desktop destinations remain available with one display.\n\nThe UI, CLI, and daemon use the same verified movement operation. If a window\nis on an inactive desktop, Lattices first carries it onto its source display's\ncurrent desktop. It then transfers the window to the chosen display and\ndesktop. Mission Control can appear during the move. Keep the pointer free\nuntil the operation finishes.\n\nA move succeeds only after Lattices verifies the final display, desktop, and\nframe. If a step fails, Lattices attempts to restore the original location\nand reports the failure. The interface keeps the selection and refreshes the\nobserved window state.\n\n## Space navigation\n\n\"Go to\" commands can switch macOS Spaces to reach a window on a\ndifferent desktop. The app uses a three-path fallback:\n\n1. **CGWindowList** (needs Screen Recording) — looks up the window\n   by title tag, finds its Space via SkyLight, switches to it, then\n   raises the window\n2. **Accessibility API** (needs Accessibility) — finds the window\n   via AXUIElement, raises it, and activates the app\n3. **AppleScript** — iterates windows by name for Terminal/iTerm2,\n   or bare-activates for other terminals\n\nWhen a window is found and focused, the app flashes a green border\nhighlight around it for ~1 second so you can spot it immediately.\n\nGrant Screen Recording and Accessibility permissions in System\nSettings > Privacy & Security for all three paths to work.\n\n### Instant space switching\n\nWith Accessibility permission, the app intercepts Ctrl+← / Ctrl+→ before\nMission Control and switches Spaces through a synthetic Dock swipe under\na screen-update freeze — the real switch path, so Dock's space model,\nMission Control, and space-change observers all stay consistent, while\nthe freeze makes it render as an instant cut. A desktop filmstrip appears\nmid-screen over a subtle neutral dimmed backdrop. The focus frame stays\ncentered while window-layout previews slide through it; the small rail below\nrepresents every desktop. The preview reads the existing desktop inventory\nwithout taking screenshots or scanning windows on each keypress.\nPressing past the first or last desktop is silent. Reduce Motion updates the\nselection without sliding; Reduce Transparency makes the tray and canvas backdrop opaque.\n\nWhile the filmstrip is visible, Control+Up, Control+Down, or Control+minus opens the full\nconnected desktop canvas in one motion. You can also open it directly under\nthe pointer: hold the right mouse button and pull upward, or hold\nControl+Option and scroll down. An ordinary right-click still opens its\ncontext menu. The opening scroll's remaining movement and momentum are\nconsumed so they cannot immediately change the new canvas camera.\n\nThe canvas stays open when you release the button or modifiers. Moving the\npointer highlights a desktop without moving the canvas; Left/Right or a\nsingle click also chooses it. Up (with or without Control) and plus zoom\ntoward the highlighted desktop. Down and minus zoom back out. Enter or a\ndouble-click lands there; Escape cancels without switching. Zooming never lands. Control+Option+scroll\nzooms around the pointer, ordinary scrolling pans, and the bottom-left minimap\nrepositions the view. Pinch, Command+scroll, and the zoom buttons also work.\nFit shows all desktops. Text fields keep their arrow keys. The Control+Option\ntiling HUD and Spatial Lens stay inactive while this navigator owns the gesture.\n\nEvery page in the main window has a **Zoom out** control in its header. It\nopens the same connected desktop canvas over the current page. **Back to…**\nreturns to that page with its draft, filters, and local view state intact.\nChoosing a sidebar page leaves the canvas and navigates normally.\n\nAgents can move the tray via\n`settings.spaceBezel.set` (`top`, `bottom`, `center`, or `travelEdge`\nto dock at the edge of travel). **Desktop shimmer** in Settings > Keyboard\nadjusts the passing light from Off to its original strength, with a subtle\n15% default. Rapid switches share one pass instead of stacking light. Changes\napply on the next switch without restarting; Reduce Motion and Reduce\nTransparency suppress the effect. Agents can also set it with\n`settings.spaceBezel.set {\"shimmerIntensity\":0.15}` (range 0–1; zero is off).\nThe filmstrip stays visible while the switch is in progress, then waits 0.85\nseconds after landing before its 0.12-second fade.\nOnly bare Ctrl+arrows are claimed by Instant Space Switching;\nCtrl+Option+arrows tile, and Ctrl+Option+Shift+arrows browse layers. While the feature is on, the app\ndisables Mission Control's \"Move left/right a space\" symbolic hotkeys\n(they would replay the slide animation); the original setting is\nrestored when the feature is toggled off or the app quits. Toggle it in\nSettings > Keyboard > \"Instant Space Switching\".\n\n## Voice commands\n\n> See [Voice Commands](/docs/voice) for the full guide.\n\nPress **Hyper+D** to open the voice command window. Hold **Option** to\nspeak, release to stop. Lattices transcribes via Vox, matches to an\nintent, and executes. Built-in commands: find, show, open, tile, kill, scan.\n\nThe provider-backed assistant can run in parallel, offering follow-up\nsuggestions in the AI corner. Configure the provider and credentials in\nSettings > Voice. Workspace chat itself uses Scout and needs no separate key.\n\n## Settings\n\nOpen via the command palette or the gear icon in the main view.\nThe settings window has five tabs:\n\n### General\n\n| Setting    | Description                                          |\n|------------|------------------------------------------------------|\n| Terminal   | Which terminal to use (auto-detected from installed) |\n| Mode       | `learning` or `auto` (see below)                     |\n| Scan Root  | Directory to scan for .lattices.json configs (type a path or click Browse) |\n| Updates    | Download the latest release and relaunch the app     |\n| Keyboard Remaps | Optional Caps Lock layer that maps hold to Hyper and tap to Escape |\n\n**Mode** controls how the app handles session interaction:\n\n- **Learning** — shows tmux keybinding hints when you detach\n  (helpful while getting used to tmux)\n- **Auto** — detaches sessions automatically (fewer prompts)\n\n### Keyboard Remaps\n\n**Keyboard remaps** are enabled by default for the laptop-friendly rule:\n\n- hold Caps Lock -> Hyper (`Control` + `Option` + `Shift` + `Command`)\n- tap Caps Lock -> Escape\n\nHolding Control + Option is quiet by default. **Control+Option+arrows** tile\nthe current window; add **Shift** to browse layers. Existing custom bindings\nremain available in Settings > Shortcuts.\n\nThe pointer picker is optional under Settings > Shortcuts > **Ctrl+Option hold**.\nWith **Spatial Lens** enabled, holding physical Control + Option reveals\neight contextual placement targets around the pointer. Choose a side to preview\nleft, right, top, or bottom placement, or choose a corner for quarter placement.\nA deliberate outer flick well beyond a side target previews the physically adjacent display in that direction;\nrelease either modifier to commit the one visible proposal. Pull back before\nrelease to return to the original display; release in the neutral center or\npress another key to cancel.\n\nRepeated placement follows the window's live geometry: half, third,\ntwo-thirds, then half again. The exact Control + Option hold deliberately\nexcludes Command and Shift, so Caps Lock continues to own the Hyper layer.\n\nRules live in `~/.lattices/keyboard-remaps.json`, and the Settings toggle\ncan turn the layer off. Keyboard remaps require Accessibility permission\nbecause they use a local event tap.\n\n### Companion\n\nShows the secure local bridge status, Mac bridge fingerprint, supported\ncapability grants, and paired iPad or iPhone devices. The paired-device\nlist shows each device fingerprint, last-seen time, and granted\ncapabilities. You can refresh the list, revoke an individual device, or\nforget all trusted companions.\n\nThe local companion bridge and trackpad proxy are off by default for\nprivacy. Turn the bridge on in Settings > Companion, or open\n`lattices://companion/enable` to enable the bridge and jump straight to\nCompanion settings. `lattices://companion/disable` turns it off again.\n\nThe trackpad proxy toggle lives here. Paired devices still need the\n`input.trackpad` grant before they can send pointer events.\n\n### AI\n\n| Setting              | Default        | Description                              |\n|----------------------|----------------|------------------------------------------|\n| Workspace chat       | Scout          | Uses the existing local Scout broker and keeps a persistent Scout ref; no separate Lattices chat credential |\n| Voice provider       | OpenAI         | Optional voice-only HudsonAI provider for interpretation and speech services |\n| Provider credentials | Not set        | API-key storage in the macOS Keychain for the selected provider |\n\nShows assistant readiness and the selected provider's authentication state.\n\n### Shortcuts\n\nShows keyboard shortcut reference. These are the defaults; change them from\nSettings > Shortcuts. Hyper means Control+Option+Shift+Command. With\nkeyboard remaps on, holding Caps Lock is Hyper.\n\nTiling and placement:\n\n| Shortcut | Action |\n|----------|--------|\n| Control+Option+arrows | Tile the front window to a half |\n| Control+Option+Return | Maximize |\n| Control+Option+C | Center |\n| Control+Option+U / I / J / K | Top-left, top-right, bottom-left, bottom-right quarter |\n| Control+Option+Shift+1 / 2 / 3 | Left, center, right third |\n| Hold Control+Option, press 1–9 | With Tile HUD enabled, tile to that cell of the 3×3 matrix; 5 maximizes. Number row and numpad both work |\n| Hold Control+Option, move the pointer | With Tile HUD enabled, aim a placement; release to tile, stay centered to cancel |\n| Control+Option+D | Distribute visible windows |\n| Control+Option+O | Organize the front app's windows |\n| Control+Option+V | Fill an open 3x2 cell |\n| Control+Option+G | 4x4 grid placement |\n| Hyper+Y | Grid the visible windows by the front window's type |\n| Hyper+Space | Hyperspace window survey |\n| Hyper+G | In-place window tools |\n| Hyper+H | Window navigation hints |\n| Hyper+Z | Focus mode |\n| Hyper+X | Stage |\n\nControl+Option pointer controls are off by default. Choose Tile HUD or Spatial\nLens in Settings to enable one of those placement modes.\n\nSpaces and layers:\n\n| Shortcut | Action |\n|----------|--------|\n| Control+Left / Right | Instant Space switch |\n| Command+Option+1–9 | Aim the layer in that pad slot; release to switch. 5 shows the current layer |\n| Control+Option+Shift+arrows | Move across the layer pad |\n| Command+Option+T | Add the front window to the active layer |\n| Command+Option+0 | Classic: every window back, as if Lattices were not running |\n| Command+Option+Z | Undo the last layer edit |\n\nWindows and panels:\n\n| Shortcut | Action |\n|----------|--------|\n| Control+Option+Space | Command bar: browse, search, and slash commands |\n| Hyper+1 | Workspace home |\n| Hyper+2 | Window bezel |\n| Hyper+3 | HUD |\n| Hyper+6 | Shortcuts settings |\n| Hyper+7 | Find the mouse |\n| Hyper+L | Overview |\n| Control+Command+M | Hands-off mode |\n| Command+Shift+A | Workspace assistant |\n| Command+Shift+L | Activity log |\n\nVoice Command and Search have no default binding. Assign one in\nSettings > Shortcuts.\n\nInside tmux:\n\n| Shortcut | Action |\n|----------|--------|\n| Ctrl+B  D | Detach from session |\n| Ctrl+B  X | Kill current pane |\n| Ctrl+B  Left/Right | Move between panes |\n| Ctrl+B  Z | Zoom pane (toggle) |\n| Ctrl+B  [ | Scroll mode |\n\nHyper+L opens Studio on its Overview page; close it with Escape or `q` when the map owns keyboard\nfocus, or use the window close button. Hyper+3 opens or dismisses the HUD and\nits miniature workspace map. The Scattered and Full HUD presets intentionally\nretain a faint, non-interactive ambient surface after dismissal. Neither UI is\nthe read-only terminal [`lats map`](/docs/workspace-map) command; see that\npage for exact side effects, full-teardown steps, and tab-stack cleanup.\n\nStudio's **Layers** panel lists the Cmd+Option layers in pad order. Picking\none scopes the canvas to the layer's windows that are showing, and the\ninspector lists each of its entries with the windows it matched and where each\none is: on another desktop or display, parked, hidden, or nothing, when no\nwindow matches. Picking a layer only looks; the inspector's Cmd+Option key\nswitches to it.\n\n### In-place window tools\n\nPress Hyper+G to arrange the real windows on the current desktop without\nswitching to a thumbnail survey. Hover a real app window to reveal its dashed\n**click to select** outline, then click it to add or remove it from the\nselection. Selected windows have solid green numbered outlines; the last one\nselected is the active target for single-window actions. You can also select\nfrom the **Windows** drawer in the header or use letter hints. The bottom shelf\nshows the three-step flow—Select, Arrange, Finish—and groups actions by timing:\n\n- **Fill** expands the active selected window into open space immediately.\n- **Grid** moves two or more selected windows into a balanced grid immediately.\n- **Swap** exchanges the first two selected windows.\n- **Place** stages an exact grid position for the active selected window; it\n  applies only when you Keep changes.\n- **More** exposes tab groups, layers, quick placements, and Hyperspace.\n\nGrid, Fill, and Swap update the live desktop while Hyper+G remains open. Press\n**Keep changes** or Return to accept those live moves and apply staged changes.\nPress **Restore** or Escape to discard staged changes and restore the window\nframes and stacking order from when Hyper+G opened. Inline **Now** and **Keep**\nlabels distinguish immediate actions from staged actions before anything moves.\n\n**Snapshot** starts a three-second countdown, captures the complete display\nwithout closing or changing Hyper+G, copies the PNG to the clipboard, and saves\nthe same image as a Runs artifact. The command-line equivalent is\n`lats capture display [index] --clipboard --delay 3`.\n\n### Agent screenshots\n\nWhen a screenshot is requested through the daemon socket (`lattices capture\nwindow|display`, or any `capture.screenshot*` call), Lattices draws a coral\nhairline around what was captured for about a second. A tag names the agent,\nwhere it called from (this Mac, or `ssh <ip>`) and its project. The cue appears\nafter the shot and is excluded from screen capture, so it never shows up in an\nimage. Screenshots Lattices takes for itself don't trigger it.\n\nEach one is also appended to `~/.lattices/audit/captures.jsonl` with the caller,\ntarget and artifact path. Read it with `lattices capture log [--limit N] [--json]`.\nThe CLI detects Claude Code, Codex, opencode and Cursor from their environment.\nSet `LATTICES_AGENT` to name any other agent.\n\n### Docs\n\nEmbedded quick reference with glossary, \"how it works\" steps, and\nlinks to open the full `config.md` and `concepts.md` docs.\n\n## Supported terminals\n\n| Terminal     | Launch | Focus/Attach | Tile by tag |\n|--------------|--------|--------------|-------------|\n| Terminal.app | yes    | yes          | yes         |\n| iTerm2       | yes    | yes          | yes         |\n| Warp         | yes    | activate     | frontmost   |\n| Ghostty      | yes    | activate     | frontmost   |\n| Kitty        | yes    | activate     | frontmost   |\n| Alacritty    | yes    | activate     | frontmost   |\n\n\"yes\" means full AppleScript-based window matching by title tag.\n\"activate\" means the app is brought to front but a specific window\ncan't be targeted. \"frontmost\" means tiling applies to whatever\nwindow is in front.\n\n## Screen OCR\n\n> See [Screen OCR](/docs/ocr) for full details on configuration, scanning,\n> search, and agent usage.\n\nThe app reads text from visible windows using Apple's Vision framework\nand stores results in a local SQLite database with FTS5 full-text search.\nAgents can use this to \"see\" what's on screen.\n\n### How it works\n\n1. Every 60 seconds, the app captures the top visible windows as images\n2. A SHA256 hash detects whether the window content has changed\n3. Changed windows are processed through `VNRecognizeTextRequest` (fast mode)\n4. Results are stored in `~/.lattices/ocr.db` with full-text indexing\n5. Entries older than 3 days are automatically purged\n\n### Desktop Inventory integration\n\nDesktop Inventory and omni search use OCR to find windows by their content —\nnot just by title or app name. Matching windows can include contextual text\nsnippets. Hyper+G's optional drawer is a compact roster for the current display;\nuse Desktop Inventory or omni search when the on-screen text is the target.\n\n### API access\n\nAgents can query OCR data through four API methods:\n\n| Method         | Description                                    |\n|----------------|------------------------------------------------|\n| `ocr.snapshot` | Current OCR results for all visible windows    |\n| `ocr.search`   | Full-text search across history (FTS5 syntax)  |\n| `ocr.history`  | Timeline of OCR results for a specific window  |\n| `ocr.scan`     | Trigger an immediate scan (bypasses timer)     |\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Find windows showing error messages\nconst errors = await daemonCall('ocr.search', { query: 'error OR failed' })\n\n// Read what's currently on screen\nconst snapshot = await daemonCall('ocr.snapshot')\n```\n\nMore in the [Agent API reference](/docs/api#ocrsnapshot).\n\n### Requirements\n\n- **Screen Recording** permission — required to capture window and display images\n- Granted via System Settings > Privacy & Security > Screen Recording\n\n## Local services\n\nThe menu bar process hosts its services on private Unix sockets in\n`~/.lattices/run` (see `LatticesLocalEndpoints`). `LATTICES_RUNTIME_DIR`\nmoves that directory for isolated tests.\n\n| Service | Socket | Credential |\n|---------|--------|------------|\n| Agent API / daemon | `lattices.sock` | `lattices.token` |\n| Companion bridge | `companion.sock` | `lattices.token` |\n| Voice runtime | `voice.sock` | `voice.token` |\n| Speech | `speech.sock` | `speech.token` |\n\nThe sockets open when the app launches and close when it quits. Voice does\nnot require an external `voxd` for normal operation.\n\nEach WebSocket service requires its token in the `x-lattices-token` header\nduring the upgrade. `lats`, `daemonCall()` and the MCP server read the token\nfor you. Loopback TCP (9399 for the daemon, 9398 for voice, 9397 for\nspeech) is a compatibility switch, off by default; see\n[Private IPC and the gateway](/docs/concepts#private-ipc-and-the-gateway).\n\n## Agent API server\n\nThe menu bar app serves the agent API on `lattices.sock`. It starts when the\napp launches and stops when the app quits.\n\n### Checking status\n\n```bash\nlats daemon status\n```\n\nOr programmatically:\n\n```js\nimport { isDaemonRunning, daemonCall } from '@lattices/lattices'\n\nif (await isDaemonRunning()) {\n  const status = await daemonCall('daemon.status')\n  console.log(status) // { uptime, clientCount, version, windowCount, tmuxSessionCount }\n}\n```\n\n### What it provides\n\n- RPC methods for reading windows, sessions, projects, spaces, layers,\n  processes, terminals, and OCR. Also launching/killing sessions, tiling\n  windows, switching layers, and managing tab groups.\n- 5 real-time events (`windows.changed`, `tmux.changed`, `processes.changed`,\n  `layer.switched`, `ocr.scanComplete`) broadcast to all connected clients.\n- Window tracking that monitors the desktop window list and correlates\n  windows to lats sessions via title tags.\n- Space awareness, so it knows which macOS Space each window is on.\n\n### Security\n\nThe socket directory is mode `0700` and the socket and token are mode\n`0600`, so only your user account can connect. The daemon also checks the\nconnected peer's user id. Any program running as your user can read the\ntoken; this is an account boundary, not isolation between programs. The\ndaemon is not reachable from the network. Remote access goes through the\nseparate gateway (`lats gateway start --tailnet`) and its approved method\ngrants.\n\nFull method list in the [Agent API reference](/docs/api).\n\n## Diagnostics\n\nThe diagnostics panel shows a timestamped log of window navigation\nattempts, including which path succeeded or failed. Useful for\ndebugging Screen Recording / Accessibility permission issues.\n",
      "content": "The lattices menu bar app sits in your menu bar and controls your\nworkspace from there.\n\n## Installation\n\n```bash\nlats app              # Build (or download) and launch\nlats app install      # Register launch-at-login and launch now\nlats app update       # Swap in the latest app release only\nlats app build        # Rebuild from source\nlats app restart      # Quit, rebuild, relaunch\nlats app quit         # Stop the app\n```\n\nThe first run builds from source if Swift is available, otherwise\ndownloads a pre-built binary from GitHub releases.\n\nUse `lats app install` when you want the companion to start now and\nopen automatically at login. It installs a user LaunchAgent in\n`~/Library/LaunchAgents`; inspect it with `lats app login status`\nand remove it with `lats app login disable`.\n\n## Updating\n\n```bash\nlats update           # Update the CLI and app together\n```\n\n`lats update` reinstalls `@lattices/lattices` with the package manager\nthat owns your global install (npm, bun, or pnpm). It re-registers\nlaunch-at-login so the LaunchAgent points at the new install, and it\nrelaunches the app if it was running. Pass `--force` to reinstall when\nyou already have the latest version.\n\n## Command palette\n\nPress **Control+Option+Space** from anywhere to open the command palette.\nIt's a searchable list of every action the app can perform, with\nfuzzy matching on titles and subtitles.\n\n### Project commands\n\n| Command                       | Description                              |\n|-------------------------------|------------------------------------------|\n| Launch *project*              | Create a new session and open terminal   |\n| Attach *project*              | Focus or open the running session        |\n| Sync *project*                | Reconcile session to declared config     |\n| Restart *pane* in *project*   | Kill and re-run a specific pane's command |\n\n### Window commands\n\nAvailable for running sessions:\n\n| Command                       | Description                              |\n|-------------------------------|------------------------------------------|\n| Go to *project*               | Focus the terminal window (switches Spaces if needed) |\n| Tile *project* Left           | Snap window to left half                 |\n| Tile *project* Right          | Snap window to right half                |\n| Maximize *project*            | Expand window to fill screen             |\n| Detach *project*              | Disconnect clients, keep session alive   |\n| Kill *project*                | Terminate the tmux session               |\n\n### Tab group commands\n\nAvailable when `groups` are configured in `~/.lattices/workspace.json`\n(see [Tab Groups](/docs/layers#tab-groups)):\n\n| Command                     | Description                              |\n|-----------------------------|------------------------------------------|\n| Launch *group*              | Start the group session (all tabs)       |\n| Attach *group*              | Focus the running group session          |\n| *Group*: *Tab*              | Switch to a specific tab within a group  |\n| Kill *group* Group          | Terminate the group session              |\n\n### Layer commands\n\nAvailable when `layers` are configured in `~/.lattices/workspace.json`\n(see [Layers](/docs/layers#layers)):\n\n| Command                     | Description                              |\n|-----------------------------|------------------------------------------|\n| Switch to Layer: *label*    | Focus and tile the layer's project windows |\n| Show All Windows            | Put back what layer switches parked or hid |\n\n### App commands\n\n| Command           | Description                              |\n|-------------------|------------------------------------------|\n| Settings          | Open preferences (terminal, scan root)   |\n| Update Lattices   | Download the latest release and relaunch |\n| Diagnostics       | View logs and debug info                 |\n| Refresh Projects  | Re-scan .lattices.json configs, reload workspace.json |\n| Quit Lattices      | Exit the menu bar app                    |\n\n## Overlay actors and HUDs\n\nPersistent overlay actors can be hidden or restored with **Hyper+B**. Apps can\npublish a static hover dashboard by exposing:\n\n```txt\n.lattices/hud/manifest.json\n```\n\nRegister and publish one:\n\n```bash\nlats hud register .lattices/hud/manifest.json --publish\n```\n\nThe manifest points to a local `index.html`, optional icon, app activation\ntarget, HUD dimensions, and optional app-owned `sources` metadata for logs or\nstate files. Lattices hosts the actor and loads the dashboard through a\ntransparent `WKWebView`; the app owns the renderer and writes its own logs in\nthe places it already uses.\n\n## Project discovery\n\nThe app scans a configurable root directory (up to 3 levels deep)\nfor `.lattices.json` files. It skips `.git/` and `node_modules/`.\n\nAuto-detection for the scan root checks these paths in order:\n`~/dev`, `~/Developer`, `~/projects`, `~/src`.\n\nFor each project found, the app reads:\n- Pane names and commands from `.lattices.json`\n- Dev command and package manager from `package.json`\n- Running status by checking `tmux has-session`\n\n## Session management\n\nThe app calls the lattices CLI for session operations. Launch runs\n`lats start` in the project directory, Sync runs `lats sync` to\nreconcile panes, and Restart runs `lats restart <pane>` to kill\nand re-run a pane's process. Detach and Kill call `tmux detach-client`\nand `tmux kill-session` directly.\n\n## Window tiling\n\nThe app can tile terminal windows to preset screen positions via\nthe command palette. It finds windows by their `[lattices:session-name]`\ntitle tag.\n\nFor Terminal.app and iTerm2, tiling uses AppleScript to set window\nbounds by matching the title tag. For other terminals, it tiles the\nfrontmost window.\n\n### Tile positions (app)\n\n| Position     | Area                            |\n|--------------|---------------------------------|\n| Left         | Left half                       |\n| Right        | Right half                      |\n| Top Left     | Top-left quarter                |\n| Top Right    | Top-right quarter               |\n| Bottom Left  | Bottom-left quarter             |\n| Bottom Right | Bottom-right quarter            |\n| Maximize     | Full visible screen             |\n| Left Third   | Left third                      |\n| Center Third | Center third                    |\n| Right Third  | Right third                     |\n| Center       | 80% width, 80% height, centered (20% margin all sides) |\n\n## Moving windows between displays and desktops\n\nIn **Overview**, select a window and open **Move** in the floating controls.\nEach destination includes the display name and desktop number, such as\n**DELL S3422DWG · Desktop 2** or **Action Agent Layer · Desktop 2**.\n**Bring Here** selects the current desktop on the display that contains the\nOverview window.\n\nDrag a window shape onto another display map to move it to that display's\ncurrent desktop. Drop it onto a desktop thumbnail to choose that exact\ndesktop. The destination highlight identifies the drop target. Selecting a\nwindow shows its controls over the canvas; the display maps and arrangement\ntray keep their positions.\n\nDesktop Inventory and Studio also offer window movement in their context\nmenus:\n\n- **Bring Here** moves the selection to the UI's display and current desktop.\n- **Move to Desktop** lists each display and its desktops.\n- **Move to Next Monitor** cycles through displays and preserves normalized\n  window position and size.\n- **Move to Monitor** selects a display's current desktop.\n- **Move & Place** selects a display and a placement slot for one window.\n\nA right-click on a member of a multi-selection targets the whole selection.\nEach window keeps its own normalized frame. Move & Place is available for a\nsingle window. Desktop destinations remain available with one display.\n\nThe UI, CLI, and daemon use the same verified movement operation. If a window\nis on an inactive desktop, Lattices first carries it onto its source display's\ncurrent desktop. It then transfers the window to the chosen display and\ndesktop. Mission Control can appear during the move. Keep the pointer free\nuntil the operation finishes.\n\nA move succeeds only after Lattices verifies the final display, desktop, and\nframe. If a step fails, Lattices attempts to restore the original location\nand reports the failure. The interface keeps the selection and refreshes the\nobserved window state.\n\n## Space navigation\n\n\"Go to\" commands can switch macOS Spaces to reach a window on a\ndifferent desktop. The app uses a three-path fallback:\n\n1. **CGWindowList** (needs Screen Recording) — looks up the window\n   by title tag, finds its Space via SkyLight, switches to it, then\n   raises the window\n2. **Accessibility API** (needs Accessibility) — finds the window\n   via AXUIElement, raises it, and activates the app\n3. **AppleScript** — iterates windows by name for Terminal/iTerm2,\n   or bare-activates for other terminals\n\nWhen a window is found and focused, the app flashes a green border\nhighlight around it for ~1 second so you can spot it immediately.\n\nGrant Screen Recording and Accessibility permissions in System\nSettings > Privacy & Security for all three paths to work.\n\n### Instant space switching\n\nWith Accessibility permission, the app intercepts Ctrl+← / Ctrl+→ before\nMission Control and switches Spaces through a synthetic Dock swipe under\na screen-update freeze — the real switch path, so Dock's space model,\nMission Control, and space-change observers all stay consistent, while\nthe freeze makes it render as an instant cut. A desktop filmstrip appears\nmid-screen over a subtle neutral dimmed backdrop. The focus frame stays\ncentered while window-layout previews slide through it; the small rail below\nrepresents every desktop. The preview reads the existing desktop inventory\nwithout taking screenshots or scanning windows on each keypress.\nPressing past the first or last desktop is silent. Reduce Motion updates the\nselection without sliding; Reduce Transparency makes the tray and canvas backdrop opaque.\n\nWhile the filmstrip is visible, Control+Up, Control+Down, or Control+minus opens the full\nconnected desktop canvas in one motion. You can also open it directly under\nthe pointer: hold the right mouse button and pull upward, or hold\nControl+Option and scroll down. An ordinary right-click still opens its\ncontext menu. The opening scroll's remaining movement and momentum are\nconsumed so they cannot immediately change the new canvas camera.\n\nThe canvas stays open when you release the button or modifiers. Moving the\npointer highlights a desktop without moving the canvas; Left/Right or a\nsingle click also chooses it. Up (with or without Control) and plus zoom\ntoward the highlighted desktop. Down and minus zoom back out. Enter or a\ndouble-click lands there; Escape cancels without switching. Zooming never lands. Control+Option+scroll\nzooms around the pointer, ordinary scrolling pans, and the bottom-left minimap\nrepositions the view. Pinch, Command+scroll, and the zoom buttons also work.\nFit shows all desktops. Text fields keep their arrow keys. The Control+Option\ntiling HUD and Spatial Lens stay inactive while this navigator owns the gesture.\n\nEvery page in the main window has a **Zoom out** control in its header. It\nopens the same connected desktop canvas over the current page. **Back to…**\nreturns to that page with its draft, filters, and local view state intact.\nChoosing a sidebar page leaves the canvas and navigates normally.\n\nAgents can move the tray via\n`settings.spaceBezel.set` (`top`, `bottom`, `center`, or `travelEdge`\nto dock at the edge of travel). **Desktop shimmer** in Settings > Keyboard\nadjusts the passing light from Off to its original strength, with a subtle\n15% default. Rapid switches share one pass instead of stacking light. Changes\napply on the next switch without restarting; Reduce Motion and Reduce\nTransparency suppress the effect. Agents can also set it with\n`settings.spaceBezel.set {\"shimmerIntensity\":0.15}` (range 0–1; zero is off).\nThe filmstrip stays visible while the switch is in progress, then waits 0.85\nseconds after landing before its 0.12-second fade.\nOnly bare Ctrl+arrows are claimed by Instant Space Switching;\nCtrl+Option+arrows tile, and Ctrl+Option+Shift+arrows browse layers. While the feature is on, the app\ndisables Mission Control's \"Move left/right a space\" symbolic hotkeys\n(they would replay the slide animation); the original setting is\nrestored when the feature is toggled off or the app quits. Toggle it in\nSettings > Keyboard > \"Instant Space Switching\".\n\n## Voice commands\n\n> See [Voice Commands](/docs/voice) for the full guide.\n\nPress **Hyper+D** to open the voice command window. Hold **Option** to\nspeak, release to stop. Lattices transcribes via Vox, matches to an\nintent, and executes. Built-in commands: find, show, open, tile, kill, scan.\n\nThe provider-backed assistant can run in parallel, offering follow-up\nsuggestions in the AI corner. Configure the provider and credentials in\nSettings > Voice. Workspace chat itself uses Scout and needs no separate key.\n\n## Settings\n\nOpen via the command palette or the gear icon in the main view.\nThe settings window has five tabs:\n\n### General\n\n| Setting    | Description                                          |\n|------------|------------------------------------------------------|\n| Terminal   | Which terminal to use (auto-detected from installed) |\n| Mode       | `learning` or `auto` (see below)                     |\n| Scan Root  | Directory to scan for .lattices.json configs (type a path or click Browse) |\n| Updates    | Download the latest release and relaunch the app     |\n| Keyboard Remaps | Optional Caps Lock layer that maps hold to Hyper and tap to Escape |\n\n**Mode** controls how the app handles session interaction:\n\n- **Learning** — shows tmux keybinding hints when you detach\n  (helpful while getting used to tmux)\n- **Auto** — detaches sessions automatically (fewer prompts)\n\n### Keyboard Remaps\n\n**Keyboard remaps** are enabled by default for the laptop-friendly rule:\n\n- hold Caps Lock -> Hyper (`Control` + `Option` + `Shift` + `Command`)\n- tap Caps Lock -> Escape\n\nHolding Control + Option is quiet by default. **Control+Option+arrows** tile\nthe current window; add **Shift** to browse layers. Existing custom bindings\nremain available in Settings > Shortcuts.\n\nThe pointer picker is optional under Settings > Shortcuts > **Ctrl+Option hold**.\nWith **Spatial Lens** enabled, holding physical Control + Option reveals\neight contextual placement targets around the pointer. Choose a side to preview\nleft, right, top, or bottom placement, or choose a corner for quarter placement.\nA deliberate outer flick well beyond a side target previews the physically adjacent display in that direction;\nrelease either modifier to commit the one visible proposal. Pull back before\nrelease to return to the original display; release in the neutral center or\npress another key to cancel.\n\nRepeated placement follows the window's live geometry: half, third,\ntwo-thirds, then half again. The exact Control + Option hold deliberately\nexcludes Command and Shift, so Caps Lock continues to own the Hyper layer.\n\nRules live in `~/.lattices/keyboard-remaps.json`, and the Settings toggle\ncan turn the layer off. Keyboard remaps require Accessibility permission\nbecause they use a local event tap.\n\n### Companion\n\nShows the secure local bridge status, Mac bridge fingerprint, supported\ncapability grants, and paired iPad or iPhone devices. The paired-device\nlist shows each device fingerprint, last-seen time, and granted\ncapabilities. You can refresh the list, revoke an individual device, or\nforget all trusted companions.\n\nThe local companion bridge and trackpad proxy are off by default for\nprivacy. Turn the bridge on in Settings > Companion, or open\n`lattices://companion/enable` to enable the bridge and jump straight to\nCompanion settings. `lattices://companion/disable` turns it off again.\n\nThe trackpad proxy toggle lives here. Paired devices still need the\n`input.trackpad` grant before they can send pointer events.\n\n### AI\n\n| Setting              | Default        | Description                              |\n|----------------------|----------------|------------------------------------------|\n| Workspace chat       | Scout          | Uses the existing local Scout broker and keeps a persistent Scout ref; no separate Lattices chat credential |\n| Voice provider       | OpenAI         | Optional voice-only HudsonAI provider for interpretation and speech services |\n| Provider credentials | Not set        | API-key storage in the macOS Keychain for the selected provider |\n\nShows assistant readiness and the selected provider's authentication state.\n\n### Shortcuts\n\nShows keyboard shortcut reference. These are the defaults; change them from\nSettings > Shortcuts. Hyper means Control+Option+Shift+Command. With\nkeyboard remaps on, holding Caps Lock is Hyper.\n\nTiling and placement:\n\n| Shortcut | Action |\n|----------|--------|\n| Control+Option+arrows | Tile the front window to a half |\n| Control+Option+Return | Maximize |\n| Control+Option+C | Center |\n| Control+Option+U / I / J / K | Top-left, top-right, bottom-left, bottom-right quarter |\n| Control+Option+Shift+1 / 2 / 3 | Left, center, right third |\n| Hold Control+Option, press 1–9 | With Tile HUD enabled, tile to that cell of the 3×3 matrix; 5 maximizes. Number row and numpad both work |\n| Hold Control+Option, move the pointer | With Tile HUD enabled, aim a placement; release to tile, stay centered to cancel |\n| Control+Option+D | Distribute visible windows |\n| Control+Option+O | Organize the front app's windows |\n| Control+Option+V | Fill an open 3x2 cell |\n| Control+Option+G | 4x4 grid placement |\n| Hyper+Y | Grid the visible windows by the front window's type |\n| Hyper+Space | Hyperspace window survey |\n| Hyper+G | In-place window tools |\n| Hyper+H | Window navigation hints |\n| Hyper+Z | Focus mode |\n| Hyper+X | Stage |\n\nControl+Option pointer controls are off by default. Choose Tile HUD or Spatial\nLens in Settings to enable one of those placement modes.\n\nSpaces and layers:\n\n| Shortcut | Action |\n|----------|--------|\n| Control+Left / Right | Instant Space switch |\n| Command+Option+1–9 | Aim the layer in that pad slot; release to switch. 5 shows the current layer |\n| Control+Option+Shift+arrows | Move across the layer pad |\n| Command+Option+T | Add the front window to the active layer |\n| Command+Option+0 | Classic: every window back, as if Lattices were not running |\n| Command+Option+Z | Undo the last layer edit |\n\nWindows and panels:\n\n| Shortcut | Action |\n|----------|--------|\n| Control+Option+Space | Command bar: browse, search, and slash commands |\n| Hyper+1 | Workspace home |\n| Hyper+2 | Window bezel |\n| Hyper+3 | HUD |\n| Hyper+6 | Shortcuts settings |\n| Hyper+7 | Find the mouse |\n| Hyper+L | Overview |\n| Control+Command+M | Hands-off mode |\n| Command+Shift+A | Workspace assistant |\n| Command+Shift+L | Activity log |\n\nVoice Command and Search have no default binding. Assign one in\nSettings > Shortcuts.\n\nInside tmux:\n\n| Shortcut | Action |\n|----------|--------|\n| Ctrl+B  D | Detach from session |\n| Ctrl+B  X | Kill current pane |\n| Ctrl+B  Left/Right | Move between panes |\n| Ctrl+B  Z | Zoom pane (toggle) |\n| Ctrl+B  [ | Scroll mode |\n\nHyper+L opens Studio on its Overview page; close it with Escape or `q` when the map owns keyboard\nfocus, or use the window close button. Hyper+3 opens or dismisses the HUD and\nits miniature workspace map. The Scattered and Full HUD presets intentionally\nretain a faint, non-interactive ambient surface after dismissal. Neither UI is\nthe read-only terminal [`lats map`](/docs/workspace-map) command; see that\npage for exact side effects, full-teardown steps, and tab-stack cleanup.\n\nStudio's **Layers** panel lists the Cmd+Option layers in pad order. Picking\none scopes the canvas to the layer's windows that are showing, and the\ninspector lists each of its entries with the windows it matched and where each\none is: on another desktop or display, parked, hidden, or nothing, when no\nwindow matches. Picking a layer only looks; the inspector's Cmd+Option key\nswitches to it.\n\n### In-place window tools\n\nPress Hyper+G to arrange the real windows on the current desktop without\nswitching to a thumbnail survey. Hover a real app window to reveal its dashed\n**click to select** outline, then click it to add or remove it from the\nselection. Selected windows have solid green numbered outlines; the last one\nselected is the active target for single-window actions. You can also select\nfrom the **Windows** drawer in the header or use letter hints. The bottom shelf\nshows the three-step flow—Select, Arrange, Finish—and groups actions by timing:\n\n- **Fill** expands the active selected window into open space immediately.\n- **Grid** moves two or more selected windows into a balanced grid immediately.\n- **Swap** exchanges the first two selected windows.\n- **Place** stages an exact grid position for the active selected window; it\n  applies only when you Keep changes.\n- **More** exposes tab groups, layers, quick placements, and Hyperspace.\n\nGrid, Fill, and Swap update the live desktop while Hyper+G remains open. Press\n**Keep changes** or Return to accept those live moves and apply staged changes.\nPress **Restore** or Escape to discard staged changes and restore the window\nframes and stacking order from when Hyper+G opened. Inline **Now** and **Keep**\nlabels distinguish immediate actions from staged actions before anything moves.\n\n**Snapshot** starts a three-second countdown, captures the complete display\nwithout closing or changing Hyper+G, copies the PNG to the clipboard, and saves\nthe same image as a Runs artifact. The command-line equivalent is\n`lats capture display [index] --clipboard --delay 3`.\n\n### Agent screenshots\n\nWhen a screenshot is requested through the daemon socket (`lattices capture\nwindow|display`, or any `capture.screenshot*` call), Lattices draws a coral\nhairline around what was captured for about a second. A tag names the agent,\nwhere it called from (this Mac, or `ssh <ip>`) and its project. The cue appears\nafter the shot and is excluded from screen capture, so it never shows up in an\nimage. Screenshots Lattices takes for itself don't trigger it.\n\nEach one is also appended to `~/.lattices/audit/captures.jsonl` with the caller,\ntarget and artifact path. Read it with `lattices capture log [--limit N] [--json]`.\nThe CLI detects Claude Code, Codex, opencode and Cursor from their environment.\nSet `LATTICES_AGENT` to name any other agent.\n\n### Docs\n\nEmbedded quick reference with glossary, \"how it works\" steps, and\nlinks to open the full `config.md` and `concepts.md` docs.\n\n## Supported terminals\n\n| Terminal     | Launch | Focus/Attach | Tile by tag |\n|--------------|--------|--------------|-------------|\n| Terminal.app | yes    | yes          | yes         |\n| iTerm2       | yes    | yes          | yes         |\n| Warp         | yes    | activate     | frontmost   |\n| Ghostty      | yes    | activate     | frontmost   |\n| Kitty        | yes    | activate     | frontmost   |\n| Alacritty    | yes    | activate     | frontmost   |\n\n\"yes\" means full AppleScript-based window matching by title tag.\n\"activate\" means the app is brought to front but a specific window\ncan't be targeted. \"frontmost\" means tiling applies to whatever\nwindow is in front.\n\n## Screen OCR\n\n> See [Screen OCR](/docs/ocr) for full details on configuration, scanning,\n> search, and agent usage.\n\nThe app reads text from visible windows using Apple's Vision framework\nand stores results in a local SQLite database with FTS5 full-text search.\nAgents can use this to \"see\" what's on screen.\n\n### How it works\n\n1. Every 60 seconds, the app captures the top visible windows as images\n2. A SHA256 hash detects whether the window content has changed\n3. Changed windows are processed through `VNRecognizeTextRequest` (fast mode)\n4. Results are stored in `~/.lattices/ocr.db` with full-text indexing\n5. Entries older than 3 days are automatically purged\n\n### Desktop Inventory integration\n\nDesktop Inventory and omni search use OCR to find windows by their content —\nnot just by title or app name. Matching windows can include contextual text\nsnippets. Hyper+G's optional drawer is a compact roster for the current display;\nuse Desktop Inventory or omni search when the on-screen text is the target.\n\n### API access\n\nAgents can query OCR data through four API methods:\n\n| Method         | Description                                    |\n|----------------|------------------------------------------------|\n| `ocr.snapshot` | Current OCR results for all visible windows    |\n| `ocr.search`   | Full-text search across history (FTS5 syntax)  |\n| `ocr.history`  | Timeline of OCR results for a specific window  |\n| `ocr.scan`     | Trigger an immediate scan (bypasses timer)     |\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Find windows showing error messages\nconst errors = await daemonCall('ocr.search', { query: 'error OR failed' })\n\n// Read what's currently on screen\nconst snapshot = await daemonCall('ocr.snapshot')\n```\n\nMore in the [Agent API reference](/docs/api#ocrsnapshot).\n\n### Requirements\n\n- **Screen Recording** permission — required to capture window and display images\n- Granted via System Settings > Privacy & Security > Screen Recording\n\n## Local services\n\nThe menu bar process hosts its services on private Unix sockets in\n`~/.lattices/run` (see `LatticesLocalEndpoints`). `LATTICES_RUNTIME_DIR`\nmoves that directory for isolated tests.\n\n| Service | Socket | Credential |\n|---------|--------|------------|\n| Agent API / daemon | `lattices.sock` | `lattices.token` |\n| Companion bridge | `companion.sock` | `lattices.token` |\n| Voice runtime | `voice.sock` | `voice.token` |\n| Speech | `speech.sock` | `speech.token` |\n\nThe sockets open when the app launches and close when it quits. Voice does\nnot require an external `voxd` for normal operation.\n\nEach WebSocket service requires its token in the `x-lattices-token` header\nduring the upgrade. `lats`, `daemonCall()` and the MCP server read the token\nfor you. Loopback TCP (9399 for the daemon, 9398 for voice, 9397 for\nspeech) is a compatibility switch, off by default; see\n[Private IPC and the gateway](/docs/concepts#private-ipc-and-the-gateway).\n\n## Agent API server\n\nThe menu bar app serves the agent API on `lattices.sock`. It starts when the\napp launches and stops when the app quits.\n\n### Checking status\n\n```bash\nlats daemon status\n```\n\nOr programmatically:\n\n```js\nimport { isDaemonRunning, daemonCall } from '@lattices/lattices'\n\nif (await isDaemonRunning()) {\n  const status = await daemonCall('daemon.status')\n  console.log(status) // { uptime, clientCount, version, windowCount, tmuxSessionCount }\n}\n```\n\n### What it provides\n\n- RPC methods for reading windows, sessions, projects, spaces, layers,\n  processes, terminals, and OCR. Also launching/killing sessions, tiling\n  windows, switching layers, and managing tab groups.\n- 5 real-time events (`windows.changed`, `tmux.changed`, `processes.changed`,\n  `layer.switched`, `ocr.scanComplete`) broadcast to all connected clients.\n- Window tracking that monitors the desktop window list and correlates\n  windows to lats sessions via title tags.\n- Space awareness, so it knows which macOS Space each window is on.\n\n### Security\n\nThe socket directory is mode `0700` and the socket and token are mode\n`0600`, so only your user account can connect. The daemon also checks the\nconnected peer's user id. Any program running as your user can read the\ntoken; this is an account boundary, not isolation between programs. The\ndaemon is not reachable from the network. Remote access goes through the\nseparate gateway (`lats gateway start --tailnet`) and its approved method\ngrants.\n\nFull method list in the [Agent API reference](/docs/api).\n\n## Diagnostics\n\nThe diagnostics panel shows a timestamped log of window navigation\nattempts, including which path succeeded or failed. Useful for\ndebugging Screen Recording / Accessibility permission issues."
    },
    {
      "id": "lattices:config",
      "slug": "config",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Configuration",
      "description": "CLI commands, .lattices.json format, and tile positions",
      "order": 2,
      "sourcePath": "docs/config.md",
      "url": "/lattices/docs/config",
      "rawUrl": "/lattices/docs/markdown/config.md",
      "markdownUrl": "/lattices/docs/config.md",
      "headings": [
        {
          "depth": 2,
          "text": ".lattices.json",
          "anchor": "lattices-json"
        },
        {
          "depth": 3,
          "text": "Minimal example",
          "anchor": "minimal-example"
        },
        {
          "depth": 3,
          "text": "Full example",
          "anchor": "full-example"
        },
        {
          "depth": 2,
          "text": "Config fields",
          "anchor": "config-fields"
        },
        {
          "depth": 2,
          "text": "Pane fields",
          "anchor": "pane-fields"
        },
        {
          "depth": 2,
          "text": "Layouts",
          "anchor": "layouts"
        },
        {
          "depth": 3,
          "text": "2 panes — side by side",
          "anchor": "2-panes-side-by-side"
        },
        {
          "depth": 3,
          "text": "3+ panes — main-vertical",
          "anchor": "3-panes-main-vertical"
        },
        {
          "depth": 3,
          "text": "4 panes",
          "anchor": "4-panes"
        },
        {
          "depth": 2,
          "text": "Auto-detection (no config)",
          "anchor": "auto-detection-no-config"
        },
        {
          "depth": 2,
          "text": "Creating a config",
          "anchor": "creating-a-config"
        },
        {
          "depth": 2,
          "text": "CLI commands",
          "anchor": "cli-commands"
        },
        {
          "depth": 2,
          "text": "Keyboard remaps",
          "anchor": "keyboard-remaps"
        },
        {
          "depth": 2,
          "text": "Machine-readable output",
          "anchor": "machine-readable-output"
        },
        {
          "depth": 3,
          "text": "--json flag",
          "anchor": "json-flag"
        },
        {
          "depth": 3,
          "text": "Daemon responses",
          "anchor": "daemon-responses"
        },
        {
          "depth": 3,
          "text": "Exit codes",
          "anchor": "exit-codes"
        },
        {
          "depth": 2,
          "text": "Recovery",
          "anchor": "recovery"
        },
        {
          "depth": 3,
          "text": "sync",
          "anchor": "sync"
        },
        {
          "depth": 3,
          "text": "restart",
          "anchor": "restart"
        },
        {
          "depth": 2,
          "text": "Tile positions",
          "anchor": "tile-positions"
        },
        {
          "depth": 3,
          "text": "Moving a specific window",
          "anchor": "moving-a-specific-window"
        },
        {
          "depth": 3,
          "text": "Smart app tiling",
          "anchor": "smart-app-tiling"
        }
      ],
      "tokensEstimate": 2498,
      "frontmatter": {
        "title": "Configuration",
        "description": "CLI commands, .lattices.json format, and tile positions",
        "order": 2
      },
      "markdown": "---\ntitle: Configuration\ndescription: CLI commands, .lattices.json format, and tile positions\norder: 2\n---\n\n## .lattices.json\n\nPlace a `.lattices.json` file in your project root to define your\nworkspace layout. lattices reads this file when creating a session.\n\n### Minimal example\n\n```json\n{\n  \"panes\": [\n    { \"name\": \"shell\" },\n    { \"name\": \"server\", \"cmd\": \"pnpm dev\" }\n  ]\n}\n```\n\n### Full example\n\n```json\n{\n  \"ensure\": true,\n  \"panes\": [\n    { \"name\": \"shell\", \"size\": 60 },\n    { \"name\": \"server\", \"cmd\": \"pnpm dev\" },\n    { \"name\": \"tests\",  \"cmd\": \"pnpm test --watch\" }\n  ]\n}\n```\n\n## Config fields\n\n| Field    | Type    | Required | Description                                          |\n|----------|---------|----------|------------------------------------------------------|\n| panes    | array   | no       | List of pane definitions (see below)                 |\n| ensure   | boolean | no       | Auto-restart exited commands on reattach              |\n| prefill  | boolean | no       | Type exited commands into idle panes on reattach (you hit Enter) |\n\n`ensure` and `prefill` are mutually exclusive. If both are set,\n`ensure` takes priority.\n\n- **ensure** — when you reattach to an existing session, lattices checks\n  each pane. If a pane's process has exited and the shell is idle, lattices\n  automatically re-runs its declared command.\n- **prefill** — same check, but the command is typed into the pane\n  without pressing Enter. You review and hit Enter yourself.\n\n## Pane fields\n\n| Field  | Type   | Required | Description                         |\n|--------|--------|----------|-------------------------------------|\n| name   | string | no       | Label for the pane (shown in app)   |\n| cmd    | string | no       | Command to run when pane opens      |\n| size   | number | no       | Width % for the first pane (1-99)   |\n\n- `size` only applies to the **first pane**. It sets the width of the\n  main pane as a percentage. Default is 60.\n- `cmd` can be any shell command. If omitted, the pane opens a shell.\n- `name` is used in the lats app to show a summary of your layout,\n  and as a target for `lats restart <name>`.\n\n## Layouts\n\nlattices picks a layout based on how many panes you define:\n\n### 2 panes — side by side\n\n```\n┌──────────┬─────────┐\n│  shell   │ server  │\n│  (60%)   │ (40%)   │\n└──────────┴─────────┘\n```\n\nHorizontal split. First pane on the left, second on the right.\n\n### 3+ panes — main-vertical\n\n```\n┌──────────┬─────────┐\n│  shell   │ server  │\n│  (60%)   ├─────────┤\n│          │ tests   │\n└──────────┴─────────┘\n```\n\nFirst pane takes the left side. Remaining panes stack vertically\non the right.\n\n### 4 panes\n\n```\n┌──────────┬─────────┐\n│  shell   │ server  │\n│  (60%)   ├─────────┤\n│          │ tests   │\n│          ├─────────┤\n│          │ logs    │\n└──────────┴─────────┘\n```\n\n## Auto-detection (no config)\n\nIf there's no `.lattices.json`, lattices still works. It will:\n\n1. Create a 2-pane layout (60/40 split)\n2. Open a shell in the left pane\n3. Auto-detect your dev command from package.json scripts and run it on the right:\n   - Looks for: `dev`, `start`, `serve`, `watch` (in that order)\n   - Detects package manager: bun > pnpm > yarn > npm\n\n## Creating a config\n\nRun `lats init` in your project directory to generate a starter\n`.lattices.json` based on your project. The generated config includes\n`\"ensure\": true` by default.\n\n## CLI commands\n\n| Command                    | Description                                      |\n|----------------------------|--------------------------------------------------|\n| `lats`                   | Show workspace status and common commands         |\n| `lats start`             | Create or attach to session for current project   |\n| `lattices tmux`              | Alias for `lats start`                        |\n| `lats init`              | Generate .lattices.json config for this project     |\n| `lats ls`                | List active sessions (requires tmux)              |\n| `lats kill [name]`       | Kill a session (defaults to current project)      |\n| `lats sync`              | Reconcile session to match declared config        |\n| `lats restart [pane]`    | Restart a pane's process (by name or index)       |\n| `lats tile <position>`   | Tile the frontmost window to a screen position    |\n| `lats tile family [app] [region]` | Smart-grid the frontmost app family, or a named app |\n| `lats window move <wid> --display <n> [--placement <slot>]` | Move a window to another display |\n| `lats window place <wid> <slot> [--display <n>]` | Snap a window into a placement slot |\n| `lats distribute [app] [region]` | Smart-grid visible windows or just one app      |\n| `lats group [id]`        | List tab groups or launch/attach a group          |\n| `lats groups`            | List all tab groups with status                   |\n| `lats tab <group> [tab]` | Switch tab within a group (by label or index)     |\n| `lats app`               | Launch the menu bar companion app                 |\n| `lats app install`       | Register launch-at-login and start now            |\n| `lats app login status`  | Show launch-at-login registration                 |\n| `lats app login disable` | Disable launch-at-login                           |\n| `lats update`            | Update lattices (CLI + app), keep startup, relaunch |\n| `lats app update`        | Swap in the latest app release only (not the CLI) |\n| `lats app build`         | Rebuild the menu bar app from source              |\n| `lats app restart`       | Rebuild and relaunch the menu bar app             |\n| `lats app quit`          | Stop the menu bar app                             |\n| `lats layer [name\\|slot]` | Switch to a workspace layer by name or pad slot (1-4, 6-9) |\n| `lats windows [--json]`  | List all visible windows                          |\n| [`lats map [--json]`](/docs/workspace-map) | Read-only current-Space terminal/JSON map |\n| `lats layer add wid:N --to <layer>` | Add a window to a layer               |\n| `lats layer remove wid:N --from <layer>` | Take a window out of a layer     |\n| `lats actor toggle`      | Hide/show persistent overlay actors               |\n| `lats hud register [manifest]` | Register a `.lattices/hud/manifest.json`   |\n| `lats hud publish [id\\|manifest]` | Publish a static HUD actor to the desktop |\n| `lats hud sync`          | Publish all registered HUD actors                 |\n| `lats search <query>`      | Search windows by title, app, session, OCR       |\n| `lats search <q> --deep`   | Deep search: index + live terminal inspection    |\n| `lats search <q> --all`    | Same as `--deep` (all search sources)            |\n| `lats search <q> --wid`    | Print matching window IDs only (pipeable)        |\n| `lats place <query> [pos]` | Deep search + focus + tile (default: bottom-right)|\n| `lats focus <session>`   | Focus a session's window and switch Spaces        |\n| `lats scan search <query>` | Search indexed screen text                       |\n| `lats action`            | Action product status (install, agent, checkout)  |\n| `lats action install`    | Download the latest Action.app release to /Applications |\n| `lats action launch`     | Open Action.app (`--launch` also works on install) |\n| `lats action quit`       | Quit Action.app                                   |\n| `lats action call <m>`   | Raw Action agent call over its private socket     |\n| `lats action <cmd>`      | Forward to the Action CLI (monorepo checkout)     |\n| `lattices diag [limit]`       | Show recent diagnostic entries                   |\n| `lats help`              | Show help                                         |\n\nAliases: `ls`/`list`, `kill`/`rm`, `sync`/`reconcile`,\n`restart`/`respawn`, `tile`/`t`.\n\n## Keyboard remaps\n\nThe menu bar app can create a lightweight keyboard layer from\n`~/.lattices/keyboard-remaps.json`. The default config is:\n\n```json\n{\n  \"rules\": [\n    {\n      \"enabled\": true,\n      \"from\": \"caps_lock\",\n      \"id\": \"caps_lock_hyper_escape\",\n      \"toIfAlone\": \"escape\",\n      \"toIfHeld\": \"hyper\"\n    }\n  ]\n}\n```\n\nIt is enabled by default and can be turned off from Settings -> General ->\nKeyboard remaps. Hold Caps Lock to send Hyper (`Control` + `Option` +\n`Shift` + `Command`), or tap Caps Lock alone to send Escape.\n\n## Machine-readable output\n\n### `--json` flag\n\n`lats windows --json` returns the raw window array.\n[`lats map --json`](/docs/workspace-map) returns a versioned, per-display\ncurrent-Space snapshot with coordinate metadata:\n\n```bash\nlats windows --json\nlats map --json\n```\n\nBoth are useful for piping into `jq` or consuming from scripts.\n\n### Daemon responses\n\nAll agent API calls return JSON natively. If you need structured data\nfrom lattices, the daemon is easier than parsing stdout. See the\n[API reference](/docs/api).\n\n### Exit codes\n\n| Code | Meaning                                     |\n|------|---------------------------------------------|\n| `0`  | Success                                     |\n| `1`  | General error (missing args, bad config)    |\n| `2`  | Session not found                           |\n\n## Recovery\n\n### sync\n\n```\nlats sync\n```\n\nReconciles a running session to match the declared config:\n\n1. Counts actual panes vs declared panes\n2. Recreates any missing panes\n3. Re-applies the layout (main-vertical with correct width)\n4. Restores pane labels\n5. Re-runs declared commands in any idle panes\n\nUse when a pane was killed and you want to get back to the declared\nstate without killing the whole session.\n\n### restart\n\n```\nlats restart [target]\n```\n\nKills the process in a specific pane and re-runs its declared command.\nThe target can be:\n\n- A **pane name** (case-insensitive): `lats restart server`\n- A **0-based index**: `lats restart 1`\n- **Omitted** (defaults to pane 0): `lats restart`\n\nThe restart sequence: send Ctrl-C, wait 0.5s, check if the process\nstopped. If it's still running, escalate to SIGKILL on child\nprocesses. Then send the declared command.\n\n## Tile positions\n\nThe `lats tile` command moves the frontmost window to a preset\nscreen position. Available positions:\n\n| Position       | Area                        |\n|----------------|-----------------------------|\n| `left`         | Left half                   |\n| `right`        | Right half                  |\n| `top`          | Top half                    |\n| `bottom`       | Bottom half                 |\n| `top-left`     | Top-left quarter            |\n| `top-right`    | Top-right quarter           |\n| `bottom-left`  | Bottom-left quarter         |\n| `bottom-right` | Bottom-right quarter        |\n| `maximize`     | Full screen (visible area)  |\n| `left-third`   | Left third                  |\n| `center-third` | Center third                |\n| `right-third`  | Right third                 |\n| `center`       | 80% width, 80% height, centered (20% margin all sides) |\n\nAliases: `left-half`/`left`, `right-half`/`right`, `top-half`/`top`,\n`bottom-half`/`bottom`, `max`/`maximize`.\n\nTiling respects the menu bar and dock. It uses the visible desktop\narea, not the full screen.\n\nFor arbitrary cells, use compact `CxR:c,r` with 1-indexed coordinates\nfrom the top-left, or canonical `grid:CxR:c,r` with 0-indexed coordinates.\nExample: `lats tile 4x4:1,2`.\n\nWhen the menu bar app is running, `lats tile` routes through the daemon's\ncanonical `windows.place` and reports a verified receipt. Without the daemon it\nfalls back to AppleScript (frontmost app, primary display) and says so.\n\n### Moving a specific window\n\n`lats tile` always targets the frontmost window. To move a *specific*\nwindow — by the CGWindowID shown in `lats map` or `lats windows` —\nuse `lats window move` / `lats window place` (daemon required):\n\n```bash\nlats window move 4182 --display 1                  # keep relative size/position\nlats window move 4182 --display 1 --placement right\nlats window place 4182 top-left                    # slot on its current display\nlats window move 4182 --display 0 --dry-run --json       # plan without moving\n```\n\nA malformed wid is an error; these commands never fall back to the frontmost\nwindow. Slots are the named positions above plus grid placements; fractional\ntyped placements remain available via `lats call windows.place`.\n\n### Smart app tiling\n\nUse `lats tile family` when you want lattices to arrange a whole\nwindow family instead of just moving the frontmost window.\n\nExamples:\n\n```bash\nlats tile family\nlats tile family right\nlats tile family iTerm2\nlats tile family \"Google Chrome\" left\n```\n\n- With no app name, `family` means the **frontmost app**. If iTerm is\n  frontmost, lattices grids your visible iTerm windows.\n- If you pass a region (`left`, `right`, `top`, `bottom`, etc.), the\n  smart grid is constrained to that part of the screen.\n- `lats distribute` uses the same smart grid engine, but defaults to\n  **all visible windows** instead of the current app family.\n",
      "content": "## .lattices.json\n\nPlace a `.lattices.json` file in your project root to define your\nworkspace layout. lattices reads this file when creating a session.\n\n### Minimal example\n\n```json\n{\n  \"panes\": [\n    { \"name\": \"shell\" },\n    { \"name\": \"server\", \"cmd\": \"pnpm dev\" }\n  ]\n}\n```\n\n### Full example\n\n```json\n{\n  \"ensure\": true,\n  \"panes\": [\n    { \"name\": \"shell\", \"size\": 60 },\n    { \"name\": \"server\", \"cmd\": \"pnpm dev\" },\n    { \"name\": \"tests\",  \"cmd\": \"pnpm test --watch\" }\n  ]\n}\n```\n\n## Config fields\n\n| Field    | Type    | Required | Description                                          |\n|----------|---------|----------|------------------------------------------------------|\n| panes    | array   | no       | List of pane definitions (see below)                 |\n| ensure   | boolean | no       | Auto-restart exited commands on reattach              |\n| prefill  | boolean | no       | Type exited commands into idle panes on reattach (you hit Enter) |\n\n`ensure` and `prefill` are mutually exclusive. If both are set,\n`ensure` takes priority.\n\n- **ensure** — when you reattach to an existing session, lattices checks\n  each pane. If a pane's process has exited and the shell is idle, lattices\n  automatically re-runs its declared command.\n- **prefill** — same check, but the command is typed into the pane\n  without pressing Enter. You review and hit Enter yourself.\n\n## Pane fields\n\n| Field  | Type   | Required | Description                         |\n|--------|--------|----------|-------------------------------------|\n| name   | string | no       | Label for the pane (shown in app)   |\n| cmd    | string | no       | Command to run when pane opens      |\n| size   | number | no       | Width % for the first pane (1-99)   |\n\n- `size` only applies to the **first pane**. It sets the width of the\n  main pane as a percentage. Default is 60.\n- `cmd` can be any shell command. If omitted, the pane opens a shell.\n- `name` is used in the lats app to show a summary of your layout,\n  and as a target for `lats restart <name>`.\n\n## Layouts\n\nlattices picks a layout based on how many panes you define:\n\n### 2 panes — side by side\n\n```\n┌──────────┬─────────┐\n│  shell   │ server  │\n│  (60%)   │ (40%)   │\n└──────────┴─────────┘\n```\n\nHorizontal split. First pane on the left, second on the right.\n\n### 3+ panes — main-vertical\n\n```\n┌──────────┬─────────┐\n│  shell   │ server  │\n│  (60%)   ├─────────┤\n│          │ tests   │\n└──────────┴─────────┘\n```\n\nFirst pane takes the left side. Remaining panes stack vertically\non the right.\n\n### 4 panes\n\n```\n┌──────────┬─────────┐\n│  shell   │ server  │\n│  (60%)   ├─────────┤\n│          │ tests   │\n│          ├─────────┤\n│          │ logs    │\n└──────────┴─────────┘\n```\n\n## Auto-detection (no config)\n\nIf there's no `.lattices.json`, lattices still works. It will:\n\n1. Create a 2-pane layout (60/40 split)\n2. Open a shell in the left pane\n3. Auto-detect your dev command from package.json scripts and run it on the right:\n   - Looks for: `dev`, `start`, `serve`, `watch` (in that order)\n   - Detects package manager: bun > pnpm > yarn > npm\n\n## Creating a config\n\nRun `lats init` in your project directory to generate a starter\n`.lattices.json` based on your project. The generated config includes\n`\"ensure\": true` by default.\n\n## CLI commands\n\n| Command                    | Description                                      |\n|----------------------------|--------------------------------------------------|\n| `lats`                   | Show workspace status and common commands         |\n| `lats start`             | Create or attach to session for current project   |\n| `lattices tmux`              | Alias for `lats start`                        |\n| `lats init`              | Generate .lattices.json config for this project     |\n| `lats ls`                | List active sessions (requires tmux)              |\n| `lats kill [name]`       | Kill a session (defaults to current project)      |\n| `lats sync`              | Reconcile session to match declared config        |\n| `lats restart [pane]`    | Restart a pane's process (by name or index)       |\n| `lats tile <position>`   | Tile the frontmost window to a screen position    |\n| `lats tile family [app] [region]` | Smart-grid the frontmost app family, or a named app |\n| `lats window move <wid> --display <n> [--placement <slot>]` | Move a window to another display |\n| `lats window place <wid> <slot> [--display <n>]` | Snap a window into a placement slot |\n| `lats distribute [app] [region]` | Smart-grid visible windows or just one app      |\n| `lats group [id]`        | List tab groups or launch/attach a group          |\n| `lats groups`            | List all tab groups with status                   |\n| `lats tab <group> [tab]` | Switch tab within a group (by label or index)     |\n| `lats app`               | Launch the menu bar companion app                 |\n| `lats app install`       | Register launch-at-login and start now            |\n| `lats app login status`  | Show launch-at-login registration                 |\n| `lats app login disable` | Disable launch-at-login                           |\n| `lats update`            | Update lattices (CLI + app), keep startup, relaunch |\n| `lats app update`        | Swap in the latest app release only (not the CLI) |\n| `lats app build`         | Rebuild the menu bar app from source              |\n| `lats app restart`       | Rebuild and relaunch the menu bar app             |\n| `lats app quit`          | Stop the menu bar app                             |\n| `lats layer [name\\|slot]` | Switch to a workspace layer by name or pad slot (1-4, 6-9) |\n| `lats windows [--json]`  | List all visible windows                          |\n| [`lats map [--json]`](/docs/workspace-map) | Read-only current-Space terminal/JSON map |\n| `lats layer add wid:N --to <layer>` | Add a window to a layer               |\n| `lats layer remove wid:N --from <layer>` | Take a window out of a layer     |\n| `lats actor toggle`      | Hide/show persistent overlay actors               |\n| `lats hud register [manifest]` | Register a `.lattices/hud/manifest.json`   |\n| `lats hud publish [id\\|manifest]` | Publish a static HUD actor to the desktop |\n| `lats hud sync`          | Publish all registered HUD actors                 |\n| `lats search <query>`      | Search windows by title, app, session, OCR       |\n| `lats search <q> --deep`   | Deep search: index + live terminal inspection    |\n| `lats search <q> --all`    | Same as `--deep` (all search sources)            |\n| `lats search <q> --wid`    | Print matching window IDs only (pipeable)        |\n| `lats place <query> [pos]` | Deep search + focus + tile (default: bottom-right)|\n| `lats focus <session>`   | Focus a session's window and switch Spaces        |\n| `lats scan search <query>` | Search indexed screen text                       |\n| `lats action`            | Action product status (install, agent, checkout)  |\n| `lats action install`    | Download the latest Action.app release to /Applications |\n| `lats action launch`     | Open Action.app (`--launch` also works on install) |\n| `lats action quit`       | Quit Action.app                                   |\n| `lats action call <m>`   | Raw Action agent call over its private socket     |\n| `lats action <cmd>`      | Forward to the Action CLI (monorepo checkout)     |\n| `lattices diag [limit]`       | Show recent diagnostic entries                   |\n| `lats help`              | Show help                                         |\n\nAliases: `ls`/`list`, `kill`/`rm`, `sync`/`reconcile`,\n`restart`/`respawn`, `tile`/`t`.\n\n## Keyboard remaps\n\nThe menu bar app can create a lightweight keyboard layer from\n`~/.lattices/keyboard-remaps.json`. The default config is:\n\n```json\n{\n  \"rules\": [\n    {\n      \"enabled\": true,\n      \"from\": \"caps_lock\",\n      \"id\": \"caps_lock_hyper_escape\",\n      \"toIfAlone\": \"escape\",\n      \"toIfHeld\": \"hyper\"\n    }\n  ]\n}\n```\n\nIt is enabled by default and can be turned off from Settings -> General ->\nKeyboard remaps. Hold Caps Lock to send Hyper (`Control` + `Option` +\n`Shift` + `Command`), or tap Caps Lock alone to send Escape.\n\n## Machine-readable output\n\n### `--json` flag\n\n`lats windows --json` returns the raw window array.\n[`lats map --json`](/docs/workspace-map) returns a versioned, per-display\ncurrent-Space snapshot with coordinate metadata:\n\n```bash\nlats windows --json\nlats map --json\n```\n\nBoth are useful for piping into `jq` or consuming from scripts.\n\n### Daemon responses\n\nAll agent API calls return JSON natively. If you need structured data\nfrom lattices, the daemon is easier than parsing stdout. See the\n[API reference](/docs/api).\n\n### Exit codes\n\n| Code | Meaning                                     |\n|------|---------------------------------------------|\n| `0`  | Success                                     |\n| `1`  | General error (missing args, bad config)    |\n| `2`  | Session not found                           |\n\n## Recovery\n\n### sync\n\n```\nlats sync\n```\n\nReconciles a running session to match the declared config:\n\n1. Counts actual panes vs declared panes\n2. Recreates any missing panes\n3. Re-applies the layout (main-vertical with correct width)\n4. Restores pane labels\n5. Re-runs declared commands in any idle panes\n\nUse when a pane was killed and you want to get back to the declared\nstate without killing the whole session.\n\n### restart\n\n```\nlats restart [target]\n```\n\nKills the process in a specific pane and re-runs its declared command.\nThe target can be:\n\n- A **pane name** (case-insensitive): `lats restart server`\n- A **0-based index**: `lats restart 1`\n- **Omitted** (defaults to pane 0): `lats restart`\n\nThe restart sequence: send Ctrl-C, wait 0.5s, check if the process\nstopped. If it's still running, escalate to SIGKILL on child\nprocesses. Then send the declared command.\n\n## Tile positions\n\nThe `lats tile` command moves the frontmost window to a preset\nscreen position. Available positions:\n\n| Position       | Area                        |\n|----------------|-----------------------------|\n| `left`         | Left half                   |\n| `right`        | Right half                  |\n| `top`          | Top half                    |\n| `bottom`       | Bottom half                 |\n| `top-left`     | Top-left quarter            |\n| `top-right`    | Top-right quarter           |\n| `bottom-left`  | Bottom-left quarter         |\n| `bottom-right` | Bottom-right quarter        |\n| `maximize`     | Full screen (visible area)  |\n| `left-third`   | Left third                  |\n| `center-third` | Center third                |\n| `right-third`  | Right third                 |\n| `center`       | 80% width, 80% height, centered (20% margin all sides) |\n\nAliases: `left-half`/`left`, `right-half`/`right`, `top-half`/`top`,\n`bottom-half`/`bottom`, `max`/`maximize`.\n\nTiling respects the menu bar and dock. It uses the visible desktop\narea, not the full screen.\n\nFor arbitrary cells, use compact `CxR:c,r` with 1-indexed coordinates\nfrom the top-left, or canonical `grid:CxR:c,r` with 0-indexed coordinates.\nExample: `lats tile 4x4:1,2`.\n\nWhen the menu bar app is running, `lats tile` routes through the daemon's\ncanonical `windows.place` and reports a verified receipt. Without the daemon it\nfalls back to AppleScript (frontmost app, primary display) and says so.\n\n### Moving a specific window\n\n`lats tile` always targets the frontmost window. To move a *specific*\nwindow — by the CGWindowID shown in `lats map` or `lats windows` —\nuse `lats window move` / `lats window place` (daemon required):\n\n```bash\nlats window move 4182 --display 1                  # keep relative size/position\nlats window move 4182 --display 1 --placement right\nlats window place 4182 top-left                    # slot on its current display\nlats window move 4182 --display 0 --dry-run --json       # plan without moving\n```\n\nA malformed wid is an error; these commands never fall back to the frontmost\nwindow. Slots are the named positions above plus grid placements; fractional\ntyped placements remain available via `lats call windows.place`.\n\n### Smart app tiling\n\nUse `lats tile family` when you want lattices to arrange a whole\nwindow family instead of just moving the frontmost window.\n\nExamples:\n\n```bash\nlats tile family\nlats tile family right\nlats tile family iTerm2\nlats tile family \"Google Chrome\" left\n```\n\n- With no app name, `family` means the **frontmost app**. If iTerm is\n  frontmost, lattices grids your visible iTerm windows.\n- If you pass a region (`left`, `right`, `top`, `bottom`, etc.), the\n  smart grid is constrained to that part of the screen.\n- `lats distribute` uses the same smart grid engine, but defaults to\n  **all visible windows** instead of the current app family."
    },
    {
      "id": "lattices:layers",
      "slug": "layers",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Workspace Layers & Tab Groups",
      "description": "Group projects into switchable layers and tabbed groups",
      "order": 4,
      "sourcePath": "docs/layers.md",
      "url": "/lattices/docs/layers",
      "rawUrl": "/lattices/docs/markdown/layers.md",
      "markdownUrl": "/lattices/docs/layers.md",
      "headings": [
        {
          "depth": 2,
          "text": "Tab Groups",
          "anchor": "tab-groups"
        },
        {
          "depth": 3,
          "text": "Configuration",
          "anchor": "configuration"
        },
        {
          "depth": 3,
          "text": "How it works",
          "anchor": "how-it-works"
        },
        {
          "depth": 3,
          "text": "Tab group fields",
          "anchor": "tab-group-fields"
        },
        {
          "depth": 3,
          "text": "CLI commands",
          "anchor": "cli-commands"
        },
        {
          "depth": 3,
          "text": "Menu bar app",
          "anchor": "menu-bar-app"
        },
        {
          "depth": 3,
          "text": "Live tab stacks",
          "anchor": "live-tab-stacks"
        },
        {
          "depth": 2,
          "text": "Layers",
          "anchor": "layers"
        },
        {
          "depth": 3,
          "text": "Configuration",
          "anchor": "configuration"
        },
        {
          "depth": 3,
          "text": "App windows in layers",
          "anchor": "app-windows-in-layers"
        },
        {
          "depth": 3,
          "text": "Using groups in layers",
          "anchor": "using-groups-in-layers"
        },
        {
          "depth": 3,
          "text": "Layer fields",
          "anchor": "layer-fields"
        },
        {
          "depth": 3,
          "text": "Tile values",
          "anchor": "tile-values"
        },
        {
          "depth": 3,
          "text": "Layouts",
          "anchor": "layouts"
        },
        {
          "depth": 3,
          "text": "Switching layers",
          "anchor": "switching-layers"
        },
        {
          "depth": 3,
          "text": "Putting windows away",
          "anchor": "putting-windows-away"
        },
        {
          "depth": 3,
          "text": "Exiting layers",
          "anchor": "exiting-layers"
        },
        {
          "depth": 3,
          "text": "Named layer switching",
          "anchor": "named-layer-switching"
        },
        {
          "depth": 3,
          "text": "Adding and removing windows",
          "anchor": "adding-and-removing-windows"
        },
        {
          "depth": 3,
          "text": "Layer bezel",
          "anchor": "layer-bezel"
        },
        {
          "depth": 3,
          "text": "Programmatic switching",
          "anchor": "programmatic-switching"
        },
        {
          "depth": 2,
          "text": "Rule-backed layers",
          "anchor": "rule-backed-layers"
        },
        {
          "depth": 3,
          "text": "One layer per window",
          "anchor": "one-layer-per-window"
        },
        {
          "depth": 3,
          "text": "Layer bar",
          "anchor": "layer-bar"
        },
        {
          "depth": 2,
          "text": "Layout examples",
          "anchor": "layout-examples"
        },
        {
          "depth": 3,
          "text": "Single project",
          "anchor": "single-project"
        },
        {
          "depth": 3,
          "text": "Two-project split",
          "anchor": "two-project-split"
        },
        {
          "depth": 3,
          "text": "Mixed: apps + terminals",
          "anchor": "mixed-apps-terminals"
        },
        {
          "depth": 3,
          "text": "Group + project",
          "anchor": "group-project"
        },
        {
          "depth": 3,
          "text": "Four quadrants",
          "anchor": "four-quadrants"
        },
        {
          "depth": 2,
          "text": "Tips",
          "anchor": "tips"
        }
      ],
      "tokensEstimate": 4880,
      "frontmatter": {
        "title": "Workspace Layers & Tab Groups",
        "description": "Group projects into switchable layers and tabbed groups",
        "order": 4
      },
      "markdown": "---\ntitle: Workspace Layers & Tab Groups\ndescription: Group projects into switchable layers and tabbed groups\norder: 4\n---\n\nTwo ways to organize related projects in `~/.lattices/workspace.json`:\n\n- **Layers** — switchable contexts that focus and tile windows\n- **Tab groups** — related projects as tabs within a single terminal window\n\nBoth features are configured in the same workspace config and\nwork together.\n\n## Tab Groups\n\nTab groups let you bundle related work into one Lattices tab stack.\nA tab can be a terminal project or a native application window such as\nChrome, an editor, Notes, or a design tool. This is useful when several\nwindows belong to one topic and should share one screen position.\n\n### Configuration\n\nAdd `groups` to `~/.lattices/workspace.json`:\n\n```json\n{\n  \"name\": \"my-setup\",\n  \"groups\": [\n    {\n      \"id\": \"vox\",\n      \"label\": \"Vox\",\n      \"tabs\": [\n        { \"path\": \"/Users/you/dev/vox\", \"label\": \"Terminal\" },\n        { \"app\": \"Google Chrome\", \"title\": \"Vox\", \"url\": \"https://github.com/example/vox\", \"label\": \"Web\" },\n        { \"app\": \"Visual Studio Code\", \"title\": \"vox\", \"launch\": \"Visual Studio Code\", \"label\": \"Editor\" }\n      ]\n    }\n  ]\n}\n```\n\nProject tabs get their pane layout from their own `.lattices.json`.\nApp tabs are matched by app name and optional window-title substring.\n`url` or `launch` tells Lattices how to open a missing app tab.\n\n### How it works\n\n- Each project tab keeps its normal `<basename>-<hash>` tmux session\n- Native app tabs are tracked by `app` and optional `title`\n- When a layer references the group with a `tile`, all matched windows\n  collapse into that slot as a cross-app tab stack\n- The HUD shows a Lattices tab strip for the active layer's first group\n- The split button arranges members inside the group's configured tile; press\n  it again to collapse the windows back into their shared slot\n- You can still launch projects independently: `cd vox-ios && lats start`\n  creates its own standalone session as before\n\n### Tab group fields\n\n| Field          | Type     | Description                          |\n|----------------|----------|--------------------------------------|\n| `id`           | string   | Unique identifier for the group      |\n| `label`        | string   | Display name shown in the UI         |\n| `tabs`         | array    | List of tab definitions              |\n| `tabs[].path`  | string?  | Absolute path for a terminal project tab |\n| `tabs[].app`   | string?  | Application name for a native app tab |\n| `tabs[].title` | string?  | Window-title substring used to select the right app window |\n| `tabs[].url`   | string?  | URL to open when an app tab is missing |\n| `tabs[].launch`| string?  | Application name passed to `open -a` when missing |\n| `tabs[].label` | string?  | Tab name (defaults to directory or app name) |\n\nEach tab needs either `path` or `app`.\n\n### CLI commands\n\n```bash\nlats groups                 # List all groups with status\nlats group <id>             # Launch or attach to a group\nlats tab <group> [tab]      # Switch tab by label or index\n```\n\nExamples:\n\n```bash\nlats group vox           # Launch all Vox terminal and app tabs\nlats tab vox Editor      # Open the editor tab\nlats tab vox 0           # Switch to first tab (by index)\n```\n\n### Menu bar app\n\nTab groups appear above the project list in the menu bar panel.\nEach group row shows:\n\n- Status indicator (running/stopped)\n- Tab count badge\n- Expand/collapse to see individual tabs\n- Launch/Attach and Kill buttons\n- Per-tab \"Go\" buttons to switch and focus a specific tab\n- A grid/collapse button for changing between stack and overview\n\nThe command palette also includes group commands:\n\n| Command                    | Description                            |\n|----------------------------|----------------------------------------|\n| Launch *group*             | Start the group session                |\n| Attach *group*             | Focus the running group session        |\n| *Group*: *Tab*             | Switch to a specific tab in a group    |\n| Kill *group* Group         | Terminate the group session            |\n\n### Live tab stacks\n\nYou do not need to edit `workspace.json` for an ad-hoc group. Open\nHyperspace, select two or more windows, then right-click any selected window\nand choose **Create Tab Group from N Windows**. The same action appears when\nyou multi-select windows in the Hyper-3 sidebar and right-click. **⌘T** remains\nthe Hyperspace keyboard shortcut. Lattices immediately\nstacks those existing terminal, browser, editor, or other app windows in\nthe top-left and shows their switcher in the HUD.\n\nUse a tab button to bring that member forward. Use the split button to arrange\nthe members side by side inside the space reserved for the group, then press it\nagain to collapse back to the shared slot. Hyperspace shows active stacks in\nthe **Groupings** shelf at the bottom-right; Hyper-3 keeps the same groups in\nits bottom-right Groupings strip. The pin button restores or hides\nthe movable floating tab tools; × on the floating tools hides them without\ndestroying the group. Ungroup is a separate minus action. Live stacks are\nruntime-only; use the configured groups above when the group should return\nafter Lattices restarts.\n\nThe Workspace Assistant understands the same selection. With windows still\nselected, say **“stack these as tabs”** or **“add these up.”** Agents can use\nthe `tabStacks.*` daemon methods described in the Agent API.\n\n## Layers\n\nLayers let you group projects into switchable contexts. Define up to\neight layers and switch between them. A switch puts away what the new\nlayer doesn't use, then brings its windows to the front and tiles them.\nLayers work like virtual desktops, without macOS Spaces: see\n[Putting windows away](#putting-windows-away).\n\nAll tmux sessions stay alive across switches. Nothing is detached,\nkilled or closed. Layers only control which windows are showing.\n\n### Configuration\n\nAdd `layers` to `~/.lattices/workspace.json`:\n\n```json\n{\n  \"name\": \"my-setup\",\n  \"layers\": [\n    {\n      \"id\": \"web\",\n      \"label\": \"Web\",\n      \"projects\": [\n        { \"path\": \"/Users/you/dev/frontend\", \"tile\": \"left\" },\n        { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n      ]\n    },\n    {\n      \"id\": \"mobile\",\n      \"label\": \"Mobile\",\n      \"projects\": [\n        { \"path\": \"/Users/you/dev/ios-app\", \"tile\": \"left\" },\n        { \"path\": \"/Users/you/dev/backend\", \"tile\": \"right\" }\n      ]\n    }\n  ]\n}\n```\n\n### App windows in layers\n\nLayers aren't limited to terminal sessions. You can include any\napplication window by using the `app`, `title`, `url`, and `launch`\nfields instead of `path`:\n\n```json\n{\n  \"name\": \"hudson\",\n  \"layers\": [\n    {\n      \"id\": \"main\",\n      \"label\": \"Main\",\n      \"projects\": [\n        { \"app\": \"Google Chrome\", \"title\": \"GitHub\", \"tile\": \"left\" },\n        { \"app\": \"Vox\", \"tile\": \"top-right\", \"launch\": \"open -a Vox\" },\n        { \"path\": \"/Users/you/dev/frontend\", \"tile\": \"bottom-right\" }\n      ]\n    },\n    {\n      \"id\": \"docs\",\n      \"label\": \"Docs\",\n      \"projects\": [\n        { \"app\": \"Google Chrome\", \"url\": \"https://docs.example.com\", \"tile\": \"left\" },\n        { \"app\": \"Notes\", \"title\": \"Sprint Notes\", \"tile\": \"right\" }\n      ]\n    }\n  ]\n}\n```\n\nWhen switching to a layer, lattices matches windows by `app` name and\noptionally filters by `title` substring or `url` prefix. If `launch`\nis provided and no matching window is found, the command is executed\nto open the app.\n\n### Using groups in layers\n\nLayer projects can reference a tab group instead of a single path.\nThis lets you tile a whole group into a screen position:\n\n```json\n{\n  \"name\": \"my-setup\",\n  \"groups\": [\n    {\n      \"id\": \"vox\",\n      \"label\": \"Vox\",\n      \"tabs\": [\n        { \"path\": \"/Users/you/dev/vox-ios\", \"label\": \"iOS\" },\n        { \"path\": \"/Users/you/dev/vox-web\", \"label\": \"Website\" }\n      ]\n    }\n  ],\n  \"layers\": [\n    {\n      \"id\": \"main\",\n      \"label\": \"Main\",\n      \"projects\": [\n        { \"group\": \"vox\", \"tile\": \"top-left\" },\n        { \"path\": \"/Users/you/dev/design-system\", \"tile\": \"right\" }\n      ]\n    }\n  ]\n}\n```\n\nWhen this layer is launched, Lattices starts or focuses the \"vox\"\ngroup and stacks all of its terminal and app windows in the top-left\nquarter, alongside the design-system project on the right. Use the HUD\ntab strip to change the visible member, or its grid button to fan out\nthe whole topic.\n\n### Layer fields\n\n| Field             | Type     | Description                              |\n|-------------------|----------|------------------------------------------|\n| `name`            | string   | Workspace name (for your reference)      |\n| `layers`          | array    | List of layer definitions                |\n| `layers[].id`     | string   | Unique identifier (e.g. `\"web\"`)         |\n| `layers[].label`  | string   | Display name shown in the UI             |\n| `layers[].projects` | array  | Projects in this layer                   |\n| `layers[].layout` | string?  | Lay the windows out: `auto`, `columns` or `master-stack` (see [Layouts](#layouts)) |\n| `projects[].path` | string?  | Absolute path to project directory       |\n| `projects[].group`| string?  | Group ID (alternative to `path`)         |\n| `projects[].app`  | string?  | Application name (for non-terminal windows) |\n| `projects[].title`| string?  | Window title substring to match          |\n| `projects[].url`  | string?  | URL prefix to match (browser windows)    |\n| `projects[].launch`| string? | Shell command to launch the app if not found |\n| `projects[].tile` | string?  | Tile position (optional, see below)      |\n\nEach project entry must have either `path`, `group`, or `app` — pick one.\n\n### Tile values\n\nAny tile position from the [config reference](/docs/config#tile-positions)\nworks: `left`, `right`, `top`, `bottom`, `top-left`, `top-right`,\n`bottom-left`, `bottom-right`, `left-third`, `center-third`,\n`right-third`, `maximize`, `center`.\n\n### Layouts\n\nInstead of a `tile` per entry, a layer can lay its windows out itself:\n\n```json\n{\n  \"id\": \"web\",\n  \"label\": \"Web\",\n  \"layout\": \"auto\",\n  \"projects\": [\n    { \"app\": \"Ghostty\", \"title\": \"mini: web\" },\n    { \"app\": \"Cursor\", \"title\": \"web\" },\n    { \"app\": \"Google Chrome\", \"title\": \"localhost\" }\n  ]\n}\n```\n\n| `layout`       | Arrangement |\n|----------------|-------------|\n| `auto`         | Lanes by app: terminals and chat on the left, editors and design tools in the middle, browsers and everything else on the right. On an ultrawide (21:9 or wider) each lane is a column, 30/40/30 with all three, and stacks its windows. On a standard display the editor, or else the first window, takes the left half and the rest stack on the right. |\n| `columns`      | Equal columns in entry order, up to four on an ultrawide and three otherwise. The leftmost columns stack any extra windows. |\n| `master-stack` | The first window takes the left 62%; the rest stack on the right. |\n\nA lone window sits centred at half width on an ultrawide and fills a\nstandard display. More than three windows in a lane or column form a\ngrid two wide.\n\nThe layout applies on the main display, to the layer's windows on the\ndesktop it's showing, whenever you switch to the layer or choose it\nagain. Every window an entry matches takes part, frontmost first, and\nthe layer's first window ends up in front. Entries with their own\n`tile` or `display` keep their place.\n\n### Switching layers\n\nFour ways to switch:\n\n| Method               | How                                      |\n|----------------------|------------------------------------------|\n| **Hotkey**           | Cmd+Option+1–9 pick a slot of the [layer pad](#layer-bezel); Cmd+Option+5 shows the layer you're on; Control+Option+Shift+arrows move across the pad and stop at its edges |\n| **Layer bar**        | Click a layer pill in the menu bar panel |\n| **Command bar**      | Press Control+Option+Space and search for the layer by name |\n| **CLI**              | `lats layer <name\\|slot>`            |\n\nEvery one of them switches the same way:\n\n1. Everything the layer doesn't use is **put away** (see below)\n2. The layer's windows are **raised**, matched by `app` / `title` / `url`\n3. A layer with a `layout` **lays out** its windows\n\nChoosing the layer you're on again gathers its windows back up.\n\nTwo extras go further, and are always asked for by name:\n\n- **Tile** also moves windows with a `tile` value to that position:\n  `lats layer <name> --tile`, or `mode: \"tile\"` in the API.\n- **Launch** first starts the projects that aren't running, then tiles:\n  `lats layer <name> --launch`, **Launch Layer** in the command\n  bar, `l` in command mode, or `mode: \"launch\"`.\n\nThe app remembers which layer was last active across restarts.\n\n### Putting windows away\n\nA switch clears the main display before it raises the new layer:\n\n- **Hidden apps.** An app with nothing in the new layer is hidden, as\n  if you pressed ⌘H.\n- **Parked windows.** The other windows of an app the layer does use\n  (a second Ghostty or Chrome window, say) are parked: moved into the\n  display's bottom-right corner, where macOS leaves a sliver showing.\n  An app that also has windows on another display or desktop is parked\n  rather than hidden, so those windows stay where they are.\n  Some apps (TextEdit and other standard Cocoa windows) won't let a\n  window go off-screen; theirs stay where they are and keep showing.\n- **Scenes.** Each layer remembers what else it had showing, beyond its\n  own entries, and brings it back when you return. A window you open\n  while a layer is up comes back with that layer.\n\nOnly the main display and the desktop it's showing take part. Other\ndisplays are left alone, and a switch never reaches onto another\ndesktop or takes you there: a layer window on another desktop stays\nput. Re-tiling the active layer leaves the rest of the screen alone.\nEverything happens at the switch; nothing runs in the background.\n\n### Exiting layers\n\nChoose **Exit Layers** to return to ordinary desktop use. Lattices clears\nthe active layer, restores windows it parked, and unhides apps it hid.\nYour saved layers and window assignments remain available.\n\n```bash\nlats layer exit            # Exit layers\nlats layer off             # Same action\n```\n\nWindows stay on their existing macOS desktops. If a parked window belongs\nto a desktop that is not showing, Lattices keeps its saved position and\nretries restoration when you visit that desktop. Exiting layers does not\nmove all windows onto the current desktop.\n\nChoose a layer to enter layers again. **Show All Windows**,\n`lats layer reveal`, and `lats layer show-all` use the same exit action.\n\n### Named layer switching\n\nYou can switch layers by name from the CLI:\n\n```bash\nlats layer hudson           # Switch to the layer named \"hudson\"\nlats layer 1                # Switch to the layer on ⌘⌥1\nlats layer hudson --launch  # Start what isn't running, then tile\n```\n\nA number is a pad slot, as ⌘⌥ takes it: 1–4 and 6–9. A name is the\nlayer's `id` or `label`, so scripts don't depend on the order.\n\n### Adding and removing windows\n\nAny window can join a layer without editing `workspace.json` by hand.\nIt's saved there as an entry of its app and its title, so it comes back\nafter a restart; while it lives, it stays in the layer when its title\nchanges.\n\n```bash\nlats layer add wid:1234 --to web       # default: the active layer\nlats layer remove wid:1234 --from web\n```\n\n⌘⌥T adds the front window to the layer you're on. In the ⌘⌥Space\npreview, a digit sends the picked window to the layer in that slot and\nDelete takes it out. Hyperspace's layer piles and ⌘L write the same\nlayers. A window held by an entry that matches other windows too (a bare\napp, a project) can't be taken out on its own; edit that entry instead.\n\n### Layer bezel\n\nWhen you switch layers, a 3×3 grid flashes in the upper middle of the\nscreen, numbered like Cmd+Option+1–9. Layers fill the eight slots round\nthe middle in order: 1, 2, 3, 4, then 6, 7, 8, 9, so a ninth layer has no\nslot. The new layer's slot is lit, its name sits underneath, and slots\nwithout a layer stay dim. The middle slot holds the Lattices pointer,\nwhich turns to aim at the new layer's slot.\n\nUnder the name, the layer's apps are listed, one row each, then the apps\nof windows it had showing when you last left it. A switch only brings\nwindows on the desktop the main display is showing, so an app whose\nwindows are elsewhere says where, dimmed: Parked, Hidden, Put away,\nDesktop 2, Left display, Full screen. A running app without the layer's\nwindow says No window, and an entry with nothing running says Not open.\nLast, dimmed, come other layers' windows whose app kept them on screen\nwhen the switch tried to park them: Stayed.\n\nControl+Option+Shift+arrows move to the nearest layer that way on the pad, hopping\nthe middle: from 4, right goes to 6. At the pad's edge the grid shows the\nlayer you're on for a moment, its lit slot bumping the way you pushed.\nCmd+Option+5, or a slot without a layer, shows the layer you're on too.\n\n### Programmatic switching\n\nAgents and scripts can switch layers via the agent API:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// List available layers\nconst { layers, active } = await daemonCall('layers.list')\nconsole.log(active === null ? 'Layers off' : `Active: ${layers[active].label}`)\n\n// Switch to a layer by index\nawait daemonCall('layers.switch', { index: 0 })\n\n// Switch to a layer by name\nawait daemonCall('layers.switch', { name: 'hudson' })\n\n// Exit layers while keeping saved definitions and desktop assignments\nawait daemonCall('layers.exit')\n```\n\nThe `layers.switch` call switches as ⌘⌥ does: it puts away what the\ntarget layer doesn't use, brings its windows forward, and applies the\nlayer's `layout`. Entry `tile` placements need `mode: \"tile\"`, and\n`mode: \"launch\"` also opens what isn't running. Its `index`\ncounts layers in list order from 0, not pad slots. A\n`layer.switched` event is broadcast to all connected clients.\n`layers.list` also reports what switches have put away, under `stage`,\nand `layers.reveal` brings it all back.\n\nMore methods in the [Agent API reference](/docs/api).\n\n## Rule-backed layers\n\nAll layer definitions, including rules created in Studio, live in\n`~/.lattices/workspace.json`. If that file is absent, Lattices imports the\nold `~/.lattices/layers.json` once and leaves the old file unchanged.\nSubsequent edits belong in `workspace.json`. Exiting layers preserves\nthese definitions.\n\nGive a project entry a `match` clause to find windows by rule. A layer\nmatches a window through any of its entries. Inside one clause, every\npresent positive field must match, and every clause in `not` must fail.\n\n```json\n{\n  \"id\": \"review\",\n  \"label\": \"Review\",\n  \"projects\": [\n    {\n      \"match\": {\n        \"appEquals\": \"Google Chrome\",\n        \"titleRegex\": \"(GitHub|Pull Request)\",\n        \"not\": [{ \"titleContains\": \"Actions\" }]\n      }\n    },\n    { \"match\": { \"sessionContains\": \"lattices\" } }\n  ]\n}\n```\n\n| Field | Match |\n|-------|-------|\n| `app` | App name contains this string |\n| `appEquals` | App name exactly equals this string |\n| `appRegex` | App name matches this regular expression |\n| `titleContains` | Window title contains this string |\n| `titleEquals` | Window title exactly equals this string |\n| `titleRegex` | Window title matches this regular expression |\n| `session` | Parsed lattices tmux session exactly equals this string |\n| `sessionContains` | Parsed lattices tmux session contains this string |\n| `isOnScreen` | Window is, or is not, visible on the current Space |\n| `spaceId` | Window belongs to this macOS Space id |\n| `not` | Exclusion clauses; any match rejects the window |\n\n`app` and `titleContains` match substrings. Use `appEquals` to match an\nexact app name.\n\n### One layer per window\n\nA window belongs to one layer at most. When entries in different layers\nmatch it, the strongest claim holds it: a pin, then a `match` clause, a\ntab group, a project, and last `app` and `title`, where a longer `title`\nbeats a shorter one and a bare `app` comes last. Ties go to the earlier\nlayer, then the earlier entry. An entry with only `launch` or `url` finds\nno windows.\n\nA window added to a layer by hand (⌘⌥T, the layer bezel, `layers.assign`)\nis saved as its `app` and `title`, with a pin:\n\n```json\n{ \"app\": \"Safari\", \"title\": \"Docs\", \"saved\": true, \"pins\": [{ \"wid\": 41027, \"pid\": 812, \"app\": \"Safari\", \"title\": \"Docs\" }] }\n```\n\nAn entry saved this way (`\"saved\": true`) holds windows by its pins alone:\na second window with the same title stays with the layer whose rule reads\nit. Its `app` and `title` find a pin's window again and launch one. An\nentry you write has no `saved` and holds what its `app` and `title` match,\npins or not, so adding a window to it keeps it matching the rest, and it\nstays when that window's pin goes. Editing a saved entry's rule in\nHyperspace makes it a written one.\n\nThe pin holds the window while it lives, whatever its title turns to. A\npin whose window closes while its app runs on is dropped, and a saved entry\nit leaves with no pins goes with it. Once the app quits, a pin whose window\nis gone takes the window with exactly its app and title, unless another\nlayer's rule holds that window; a pin without a `pid`, saved before pins\nkept one, takes only a window no other layer reads. Adding a window to a\nlayer takes the pins other layers had on it.\n\n### Layer bar\n\nWhen a workspace config is loaded, a layer bar appears between the\nheader and search field in the menu bar panel:\n\n```\n lattices  2 sessions              [↔] [⟳]\n┌────────────────────────────────────────┐\n│  ● Web          ○ Mobile               │\n│  ⌥1             ⌥2                     │\n└────────────────────────────────────────┘\n Search projects...\n```\n\n- Active layer: filled green dot\n- Inactive layers: dim outline dot\n- Hotkey hints shown below each label\n\n## Layout examples\n\n### Single project\n\n```json\n{\n  \"projects\": [\n    { \"path\": \"/Users/you/dev/vox\" }\n  ]\n}\n```\n\nNo `tile` — just focuses the window wherever it is.\n\n### Two-project split\n\n```json\n{\n  \"projects\": [\n    { \"path\": \"/Users/you/dev/app\", \"tile\": \"left\" },\n    { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n  ]\n}\n```\n\n### Mixed: apps + terminals\n\n```json\n{\n  \"projects\": [\n    { \"app\": \"Google Chrome\", \"title\": \"GitHub\", \"tile\": \"left\" },\n    { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n  ]\n}\n```\n\n### Group + project\n\n```json\n{\n  \"projects\": [\n    { \"group\": \"vox\", \"tile\": \"left\" },\n    { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n  ]\n}\n```\n\n### Four quadrants\n\n```json\n{\n  \"projects\": [\n    { \"path\": \"/Users/you/dev/frontend\", \"tile\": \"top-left\" },\n    { \"path\": \"/Users/you/dev/backend\", \"tile\": \"top-right\" },\n    { \"path\": \"/Users/you/dev/mobile\", \"tile\": \"bottom-left\" },\n    { \"path\": \"/Users/you/dev/infra\", \"tile\": \"bottom-right\" }\n  ]\n}\n```\n\n## Tips\n\n- Projects don't need a `.lattices.json` config to be in a layer — any\n  directory path works. If the project has a config, lattices uses it; if\n  not, it opens a plain terminal in that directory.\n- App windows don't need any config at all — just specify `app` and\n  optionally `title` or `url` to match the right window.\n- You can have up to 9 layers (Cmd+Option+1 through Cmd+Option+9).\n- Edit `workspace.json` by hand — the app re-reads it on launch. Use\n  Refresh Projects in the command bar, or restart the app, to pick up\n  changes.\n- The `tile` field is optional. Omit it if you just want the window\n  focused without repositioning.\n- Tab groups and standalone projects can coexist in the same workspace.\n  Use groups for related project families, standalone paths for\n  individual projects.\n",
      "content": "Two ways to organize related projects in `~/.lattices/workspace.json`:\n\n- **Layers** — switchable contexts that focus and tile windows\n- **Tab groups** — related projects as tabs within a single terminal window\n\nBoth features are configured in the same workspace config and\nwork together.\n\n## Tab Groups\n\nTab groups let you bundle related work into one Lattices tab stack.\nA tab can be a terminal project or a native application window such as\nChrome, an editor, Notes, or a design tool. This is useful when several\nwindows belong to one topic and should share one screen position.\n\n### Configuration\n\nAdd `groups` to `~/.lattices/workspace.json`:\n\n```json\n{\n  \"name\": \"my-setup\",\n  \"groups\": [\n    {\n      \"id\": \"vox\",\n      \"label\": \"Vox\",\n      \"tabs\": [\n        { \"path\": \"/Users/you/dev/vox\", \"label\": \"Terminal\" },\n        { \"app\": \"Google Chrome\", \"title\": \"Vox\", \"url\": \"https://github.com/example/vox\", \"label\": \"Web\" },\n        { \"app\": \"Visual Studio Code\", \"title\": \"vox\", \"launch\": \"Visual Studio Code\", \"label\": \"Editor\" }\n      ]\n    }\n  ]\n}\n```\n\nProject tabs get their pane layout from their own `.lattices.json`.\nApp tabs are matched by app name and optional window-title substring.\n`url` or `launch` tells Lattices how to open a missing app tab.\n\n### How it works\n\n- Each project tab keeps its normal `<basename>-<hash>` tmux session\n- Native app tabs are tracked by `app` and optional `title`\n- When a layer references the group with a `tile`, all matched windows\n  collapse into that slot as a cross-app tab stack\n- The HUD shows a Lattices tab strip for the active layer's first group\n- The split button arranges members inside the group's configured tile; press\n  it again to collapse the windows back into their shared slot\n- You can still launch projects independently: `cd vox-ios && lats start`\n  creates its own standalone session as before\n\n### Tab group fields\n\n| Field          | Type     | Description                          |\n|----------------|----------|--------------------------------------|\n| `id`           | string   | Unique identifier for the group      |\n| `label`        | string   | Display name shown in the UI         |\n| `tabs`         | array    | List of tab definitions              |\n| `tabs[].path`  | string?  | Absolute path for a terminal project tab |\n| `tabs[].app`   | string?  | Application name for a native app tab |\n| `tabs[].title` | string?  | Window-title substring used to select the right app window |\n| `tabs[].url`   | string?  | URL to open when an app tab is missing |\n| `tabs[].launch`| string?  | Application name passed to `open -a` when missing |\n| `tabs[].label` | string?  | Tab name (defaults to directory or app name) |\n\nEach tab needs either `path` or `app`.\n\n### CLI commands\n\n```bash\nlats groups                 # List all groups with status\nlats group <id>             # Launch or attach to a group\nlats tab <group> [tab]      # Switch tab by label or index\n```\n\nExamples:\n\n```bash\nlats group vox           # Launch all Vox terminal and app tabs\nlats tab vox Editor      # Open the editor tab\nlats tab vox 0           # Switch to first tab (by index)\n```\n\n### Menu bar app\n\nTab groups appear above the project list in the menu bar panel.\nEach group row shows:\n\n- Status indicator (running/stopped)\n- Tab count badge\n- Expand/collapse to see individual tabs\n- Launch/Attach and Kill buttons\n- Per-tab \"Go\" buttons to switch and focus a specific tab\n- A grid/collapse button for changing between stack and overview\n\nThe command palette also includes group commands:\n\n| Command                    | Description                            |\n|----------------------------|----------------------------------------|\n| Launch *group*             | Start the group session                |\n| Attach *group*             | Focus the running group session        |\n| *Group*: *Tab*             | Switch to a specific tab in a group    |\n| Kill *group* Group         | Terminate the group session            |\n\n### Live tab stacks\n\nYou do not need to edit `workspace.json` for an ad-hoc group. Open\nHyperspace, select two or more windows, then right-click any selected window\nand choose **Create Tab Group from N Windows**. The same action appears when\nyou multi-select windows in the Hyper-3 sidebar and right-click. **⌘T** remains\nthe Hyperspace keyboard shortcut. Lattices immediately\nstacks those existing terminal, browser, editor, or other app windows in\nthe top-left and shows their switcher in the HUD.\n\nUse a tab button to bring that member forward. Use the split button to arrange\nthe members side by side inside the space reserved for the group, then press it\nagain to collapse back to the shared slot. Hyperspace shows active stacks in\nthe **Groupings** shelf at the bottom-right; Hyper-3 keeps the same groups in\nits bottom-right Groupings strip. The pin button restores or hides\nthe movable floating tab tools; × on the floating tools hides them without\ndestroying the group. Ungroup is a separate minus action. Live stacks are\nruntime-only; use the configured groups above when the group should return\nafter Lattices restarts.\n\nThe Workspace Assistant understands the same selection. With windows still\nselected, say **“stack these as tabs”** or **“add these up.”** Agents can use\nthe `tabStacks.*` daemon methods described in the Agent API.\n\n## Layers\n\nLayers let you group projects into switchable contexts. Define up to\neight layers and switch between them. A switch puts away what the new\nlayer doesn't use, then brings its windows to the front and tiles them.\nLayers work like virtual desktops, without macOS Spaces: see\n[Putting windows away](#putting-windows-away).\n\nAll tmux sessions stay alive across switches. Nothing is detached,\nkilled or closed. Layers only control which windows are showing.\n\n### Configuration\n\nAdd `layers` to `~/.lattices/workspace.json`:\n\n```json\n{\n  \"name\": \"my-setup\",\n  \"layers\": [\n    {\n      \"id\": \"web\",\n      \"label\": \"Web\",\n      \"projects\": [\n        { \"path\": \"/Users/you/dev/frontend\", \"tile\": \"left\" },\n        { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n      ]\n    },\n    {\n      \"id\": \"mobile\",\n      \"label\": \"Mobile\",\n      \"projects\": [\n        { \"path\": \"/Users/you/dev/ios-app\", \"tile\": \"left\" },\n        { \"path\": \"/Users/you/dev/backend\", \"tile\": \"right\" }\n      ]\n    }\n  ]\n}\n```\n\n### App windows in layers\n\nLayers aren't limited to terminal sessions. You can include any\napplication window by using the `app`, `title`, `url`, and `launch`\nfields instead of `path`:\n\n```json\n{\n  \"name\": \"hudson\",\n  \"layers\": [\n    {\n      \"id\": \"main\",\n      \"label\": \"Main\",\n      \"projects\": [\n        { \"app\": \"Google Chrome\", \"title\": \"GitHub\", \"tile\": \"left\" },\n        { \"app\": \"Vox\", \"tile\": \"top-right\", \"launch\": \"open -a Vox\" },\n        { \"path\": \"/Users/you/dev/frontend\", \"tile\": \"bottom-right\" }\n      ]\n    },\n    {\n      \"id\": \"docs\",\n      \"label\": \"Docs\",\n      \"projects\": [\n        { \"app\": \"Google Chrome\", \"url\": \"https://docs.example.com\", \"tile\": \"left\" },\n        { \"app\": \"Notes\", \"title\": \"Sprint Notes\", \"tile\": \"right\" }\n      ]\n    }\n  ]\n}\n```\n\nWhen switching to a layer, lattices matches windows by `app` name and\noptionally filters by `title` substring or `url` prefix. If `launch`\nis provided and no matching window is found, the command is executed\nto open the app.\n\n### Using groups in layers\n\nLayer projects can reference a tab group instead of a single path.\nThis lets you tile a whole group into a screen position:\n\n```json\n{\n  \"name\": \"my-setup\",\n  \"groups\": [\n    {\n      \"id\": \"vox\",\n      \"label\": \"Vox\",\n      \"tabs\": [\n        { \"path\": \"/Users/you/dev/vox-ios\", \"label\": \"iOS\" },\n        { \"path\": \"/Users/you/dev/vox-web\", \"label\": \"Website\" }\n      ]\n    }\n  ],\n  \"layers\": [\n    {\n      \"id\": \"main\",\n      \"label\": \"Main\",\n      \"projects\": [\n        { \"group\": \"vox\", \"tile\": \"top-left\" },\n        { \"path\": \"/Users/you/dev/design-system\", \"tile\": \"right\" }\n      ]\n    }\n  ]\n}\n```\n\nWhen this layer is launched, Lattices starts or focuses the \"vox\"\ngroup and stacks all of its terminal and app windows in the top-left\nquarter, alongside the design-system project on the right. Use the HUD\ntab strip to change the visible member, or its grid button to fan out\nthe whole topic.\n\n### Layer fields\n\n| Field             | Type     | Description                              |\n|-------------------|----------|------------------------------------------|\n| `name`            | string   | Workspace name (for your reference)      |\n| `layers`          | array    | List of layer definitions                |\n| `layers[].id`     | string   | Unique identifier (e.g. `\"web\"`)         |\n| `layers[].label`  | string   | Display name shown in the UI             |\n| `layers[].projects` | array  | Projects in this layer                   |\n| `layers[].layout` | string?  | Lay the windows out: `auto`, `columns` or `master-stack` (see [Layouts](#layouts)) |\n| `projects[].path` | string?  | Absolute path to project directory       |\n| `projects[].group`| string?  | Group ID (alternative to `path`)         |\n| `projects[].app`  | string?  | Application name (for non-terminal windows) |\n| `projects[].title`| string?  | Window title substring to match          |\n| `projects[].url`  | string?  | URL prefix to match (browser windows)    |\n| `projects[].launch`| string? | Shell command to launch the app if not found |\n| `projects[].tile` | string?  | Tile position (optional, see below)      |\n\nEach project entry must have either `path`, `group`, or `app` — pick one.\n\n### Tile values\n\nAny tile position from the [config reference](/docs/config#tile-positions)\nworks: `left`, `right`, `top`, `bottom`, `top-left`, `top-right`,\n`bottom-left`, `bottom-right`, `left-third`, `center-third`,\n`right-third`, `maximize`, `center`.\n\n### Layouts\n\nInstead of a `tile` per entry, a layer can lay its windows out itself:\n\n```json\n{\n  \"id\": \"web\",\n  \"label\": \"Web\",\n  \"layout\": \"auto\",\n  \"projects\": [\n    { \"app\": \"Ghostty\", \"title\": \"mini: web\" },\n    { \"app\": \"Cursor\", \"title\": \"web\" },\n    { \"app\": \"Google Chrome\", \"title\": \"localhost\" }\n  ]\n}\n```\n\n| `layout`       | Arrangement |\n|----------------|-------------|\n| `auto`         | Lanes by app: terminals and chat on the left, editors and design tools in the middle, browsers and everything else on the right. On an ultrawide (21:9 or wider) each lane is a column, 30/40/30 with all three, and stacks its windows. On a standard display the editor, or else the first window, takes the left half and the rest stack on the right. |\n| `columns`      | Equal columns in entry order, up to four on an ultrawide and three otherwise. The leftmost columns stack any extra windows. |\n| `master-stack` | The first window takes the left 62%; the rest stack on the right. |\n\nA lone window sits centred at half width on an ultrawide and fills a\nstandard display. More than three windows in a lane or column form a\ngrid two wide.\n\nThe layout applies on the main display, to the layer's windows on the\ndesktop it's showing, whenever you switch to the layer or choose it\nagain. Every window an entry matches takes part, frontmost first, and\nthe layer's first window ends up in front. Entries with their own\n`tile` or `display` keep their place.\n\n### Switching layers\n\nFour ways to switch:\n\n| Method               | How                                      |\n|----------------------|------------------------------------------|\n| **Hotkey**           | Cmd+Option+1–9 pick a slot of the [layer pad](#layer-bezel); Cmd+Option+5 shows the layer you're on; Control+Option+Shift+arrows move across the pad and stop at its edges |\n| **Layer bar**        | Click a layer pill in the menu bar panel |\n| **Command bar**      | Press Control+Option+Space and search for the layer by name |\n| **CLI**              | `lats layer <name\\|slot>`            |\n\nEvery one of them switches the same way:\n\n1. Everything the layer doesn't use is **put away** (see below)\n2. The layer's windows are **raised**, matched by `app` / `title` / `url`\n3. A layer with a `layout` **lays out** its windows\n\nChoosing the layer you're on again gathers its windows back up.\n\nTwo extras go further, and are always asked for by name:\n\n- **Tile** also moves windows with a `tile` value to that position:\n  `lats layer <name> --tile`, or `mode: \"tile\"` in the API.\n- **Launch** first starts the projects that aren't running, then tiles:\n  `lats layer <name> --launch`, **Launch Layer** in the command\n  bar, `l` in command mode, or `mode: \"launch\"`.\n\nThe app remembers which layer was last active across restarts.\n\n### Putting windows away\n\nA switch clears the main display before it raises the new layer:\n\n- **Hidden apps.** An app with nothing in the new layer is hidden, as\n  if you pressed ⌘H.\n- **Parked windows.** The other windows of an app the layer does use\n  (a second Ghostty or Chrome window, say) are parked: moved into the\n  display's bottom-right corner, where macOS leaves a sliver showing.\n  An app that also has windows on another display or desktop is parked\n  rather than hidden, so those windows stay where they are.\n  Some apps (TextEdit and other standard Cocoa windows) won't let a\n  window go off-screen; theirs stay where they are and keep showing.\n- **Scenes.** Each layer remembers what else it had showing, beyond its\n  own entries, and brings it back when you return. A window you open\n  while a layer is up comes back with that layer.\n\nOnly the main display and the desktop it's showing take part. Other\ndisplays are left alone, and a switch never reaches onto another\ndesktop or takes you there: a layer window on another desktop stays\nput. Re-tiling the active layer leaves the rest of the screen alone.\nEverything happens at the switch; nothing runs in the background.\n\n### Exiting layers\n\nChoose **Exit Layers** to return to ordinary desktop use. Lattices clears\nthe active layer, restores windows it parked, and unhides apps it hid.\nYour saved layers and window assignments remain available.\n\n```bash\nlats layer exit            # Exit layers\nlats layer off             # Same action\n```\n\nWindows stay on their existing macOS desktops. If a parked window belongs\nto a desktop that is not showing, Lattices keeps its saved position and\nretries restoration when you visit that desktop. Exiting layers does not\nmove all windows onto the current desktop.\n\nChoose a layer to enter layers again. **Show All Windows**,\n`lats layer reveal`, and `lats layer show-all` use the same exit action.\n\n### Named layer switching\n\nYou can switch layers by name from the CLI:\n\n```bash\nlats layer hudson           # Switch to the layer named \"hudson\"\nlats layer 1                # Switch to the layer on ⌘⌥1\nlats layer hudson --launch  # Start what isn't running, then tile\n```\n\nA number is a pad slot, as ⌘⌥ takes it: 1–4 and 6–9. A name is the\nlayer's `id` or `label`, so scripts don't depend on the order.\n\n### Adding and removing windows\n\nAny window can join a layer without editing `workspace.json` by hand.\nIt's saved there as an entry of its app and its title, so it comes back\nafter a restart; while it lives, it stays in the layer when its title\nchanges.\n\n```bash\nlats layer add wid:1234 --to web       # default: the active layer\nlats layer remove wid:1234 --from web\n```\n\n⌘⌥T adds the front window to the layer you're on. In the ⌘⌥Space\npreview, a digit sends the picked window to the layer in that slot and\nDelete takes it out. Hyperspace's layer piles and ⌘L write the same\nlayers. A window held by an entry that matches other windows too (a bare\napp, a project) can't be taken out on its own; edit that entry instead.\n\n### Layer bezel\n\nWhen you switch layers, a 3×3 grid flashes in the upper middle of the\nscreen, numbered like Cmd+Option+1–9. Layers fill the eight slots round\nthe middle in order: 1, 2, 3, 4, then 6, 7, 8, 9, so a ninth layer has no\nslot. The new layer's slot is lit, its name sits underneath, and slots\nwithout a layer stay dim. The middle slot holds the Lattices pointer,\nwhich turns to aim at the new layer's slot.\n\nUnder the name, the layer's apps are listed, one row each, then the apps\nof windows it had showing when you last left it. A switch only brings\nwindows on the desktop the main display is showing, so an app whose\nwindows are elsewhere says where, dimmed: Parked, Hidden, Put away,\nDesktop 2, Left display, Full screen. A running app without the layer's\nwindow says No window, and an entry with nothing running says Not open.\nLast, dimmed, come other layers' windows whose app kept them on screen\nwhen the switch tried to park them: Stayed.\n\nControl+Option+Shift+arrows move to the nearest layer that way on the pad, hopping\nthe middle: from 4, right goes to 6. At the pad's edge the grid shows the\nlayer you're on for a moment, its lit slot bumping the way you pushed.\nCmd+Option+5, or a slot without a layer, shows the layer you're on too.\n\n### Programmatic switching\n\nAgents and scripts can switch layers via the agent API:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// List available layers\nconst { layers, active } = await daemonCall('layers.list')\nconsole.log(active === null ? 'Layers off' : `Active: ${layers[active].label}`)\n\n// Switch to a layer by index\nawait daemonCall('layers.switch', { index: 0 })\n\n// Switch to a layer by name\nawait daemonCall('layers.switch', { name: 'hudson' })\n\n// Exit layers while keeping saved definitions and desktop assignments\nawait daemonCall('layers.exit')\n```\n\nThe `layers.switch` call switches as ⌘⌥ does: it puts away what the\ntarget layer doesn't use, brings its windows forward, and applies the\nlayer's `layout`. Entry `tile` placements need `mode: \"tile\"`, and\n`mode: \"launch\"` also opens what isn't running. Its `index`\ncounts layers in list order from 0, not pad slots. A\n`layer.switched` event is broadcast to all connected clients.\n`layers.list` also reports what switches have put away, under `stage`,\nand `layers.reveal` brings it all back.\n\nMore methods in the [Agent API reference](/docs/api).\n\n## Rule-backed layers\n\nAll layer definitions, including rules created in Studio, live in\n`~/.lattices/workspace.json`. If that file is absent, Lattices imports the\nold `~/.lattices/layers.json` once and leaves the old file unchanged.\nSubsequent edits belong in `workspace.json`. Exiting layers preserves\nthese definitions.\n\nGive a project entry a `match` clause to find windows by rule. A layer\nmatches a window through any of its entries. Inside one clause, every\npresent positive field must match, and every clause in `not` must fail.\n\n```json\n{\n  \"id\": \"review\",\n  \"label\": \"Review\",\n  \"projects\": [\n    {\n      \"match\": {\n        \"appEquals\": \"Google Chrome\",\n        \"titleRegex\": \"(GitHub|Pull Request)\",\n        \"not\": [{ \"titleContains\": \"Actions\" }]\n      }\n    },\n    { \"match\": { \"sessionContains\": \"lattices\" } }\n  ]\n}\n```\n\n| Field | Match |\n|-------|-------|\n| `app` | App name contains this string |\n| `appEquals` | App name exactly equals this string |\n| `appRegex` | App name matches this regular expression |\n| `titleContains` | Window title contains this string |\n| `titleEquals` | Window title exactly equals this string |\n| `titleRegex` | Window title matches this regular expression |\n| `session` | Parsed lattices tmux session exactly equals this string |\n| `sessionContains` | Parsed lattices tmux session contains this string |\n| `isOnScreen` | Window is, or is not, visible on the current Space |\n| `spaceId` | Window belongs to this macOS Space id |\n| `not` | Exclusion clauses; any match rejects the window |\n\n`app` and `titleContains` match substrings. Use `appEquals` to match an\nexact app name.\n\n### One layer per window\n\nA window belongs to one layer at most. When entries in different layers\nmatch it, the strongest claim holds it: a pin, then a `match` clause, a\ntab group, a project, and last `app` and `title`, where a longer `title`\nbeats a shorter one and a bare `app` comes last. Ties go to the earlier\nlayer, then the earlier entry. An entry with only `launch` or `url` finds\nno windows.\n\nA window added to a layer by hand (⌘⌥T, the layer bezel, `layers.assign`)\nis saved as its `app` and `title`, with a pin:\n\n```json\n{ \"app\": \"Safari\", \"title\": \"Docs\", \"saved\": true, \"pins\": [{ \"wid\": 41027, \"pid\": 812, \"app\": \"Safari\", \"title\": \"Docs\" }] }\n```\n\nAn entry saved this way (`\"saved\": true`) holds windows by its pins alone:\na second window with the same title stays with the layer whose rule reads\nit. Its `app` and `title` find a pin's window again and launch one. An\nentry you write has no `saved` and holds what its `app` and `title` match,\npins or not, so adding a window to it keeps it matching the rest, and it\nstays when that window's pin goes. Editing a saved entry's rule in\nHyperspace makes it a written one.\n\nThe pin holds the window while it lives, whatever its title turns to. A\npin whose window closes while its app runs on is dropped, and a saved entry\nit leaves with no pins goes with it. Once the app quits, a pin whose window\nis gone takes the window with exactly its app and title, unless another\nlayer's rule holds that window; a pin without a `pid`, saved before pins\nkept one, takes only a window no other layer reads. Adding a window to a\nlayer takes the pins other layers had on it.\n\n### Layer bar\n\nWhen a workspace config is loaded, a layer bar appears between the\nheader and search field in the menu bar panel:\n\n```\n lattices  2 sessions              [↔] [⟳]\n┌────────────────────────────────────────┐\n│  ● Web          ○ Mobile               │\n│  ⌥1             ⌥2                     │\n└────────────────────────────────────────┘\n Search projects...\n```\n\n- Active layer: filled green dot\n- Inactive layers: dim outline dot\n- Hotkey hints shown below each label\n\n## Layout examples\n\n### Single project\n\n```json\n{\n  \"projects\": [\n    { \"path\": \"/Users/you/dev/vox\" }\n  ]\n}\n```\n\nNo `tile` — just focuses the window wherever it is.\n\n### Two-project split\n\n```json\n{\n  \"projects\": [\n    { \"path\": \"/Users/you/dev/app\", \"tile\": \"left\" },\n    { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n  ]\n}\n```\n\n### Mixed: apps + terminals\n\n```json\n{\n  \"projects\": [\n    { \"app\": \"Google Chrome\", \"title\": \"GitHub\", \"tile\": \"left\" },\n    { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n  ]\n}\n```\n\n### Group + project\n\n```json\n{\n  \"projects\": [\n    { \"group\": \"vox\", \"tile\": \"left\" },\n    { \"path\": \"/Users/you/dev/api\", \"tile\": \"right\" }\n  ]\n}\n```\n\n### Four quadrants\n\n```json\n{\n  \"projects\": [\n    { \"path\": \"/Users/you/dev/frontend\", \"tile\": \"top-left\" },\n    { \"path\": \"/Users/you/dev/backend\", \"tile\": \"top-right\" },\n    { \"path\": \"/Users/you/dev/mobile\", \"tile\": \"bottom-left\" },\n    { \"path\": \"/Users/you/dev/infra\", \"tile\": \"bottom-right\" }\n  ]\n}\n```\n\n## Tips\n\n- Projects don't need a `.lattices.json` config to be in a layer — any\n  directory path works. If the project has a config, lattices uses it; if\n  not, it opens a plain terminal in that directory.\n- App windows don't need any config at all — just specify `app` and\n  optionally `title` or `url` to match the right window.\n- You can have up to 9 layers (Cmd+Option+1 through Cmd+Option+9).\n- Edit `workspace.json` by hand — the app re-reads it on launch. Use\n  Refresh Projects in the command bar, or restart the app, to pick up\n  changes.\n- The `tile` field is optional. Omit it if you just want the window\n  focused without repositioning.\n- Tab groups and standalone projects can coexist in the same workspace.\n  Use groups for related project families, standalone paths for\n  individual projects."
    },
    {
      "id": "lattices:workspace-map",
      "slug": "workspace-map",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Workspace Map",
      "description": "Read-only terminal and JSON views of the current macOS workspace",
      "order": 5.5,
      "sourcePath": "docs/workspace-map.md",
      "url": "/lattices/docs/workspace-map",
      "rawUrl": "/lattices/docs/markdown/workspace-map.md",
      "markdownUrl": "/lattices/docs/workspace-map.md",
      "headings": [
        {
          "depth": 2,
          "text": "Terminal map",
          "anchor": "terminal-map"
        },
        {
          "depth": 3,
          "text": "The x-ray floor plan",
          "anchor": "the-x-ray-floor-plan"
        },
        {
          "depth": 2,
          "text": "Options",
          "anchor": "options"
        },
        {
          "depth": 2,
          "text": "JSON for agents",
          "anchor": "json-for-agents"
        },
        {
          "depth": 2,
          "text": "Daemon fields and coordinates",
          "anchor": "daemon-fields-and-coordinates"
        },
        {
          "depth": 2,
          "text": "Surface and side-effect matrix",
          "anchor": "surface-and-side-effect-matrix"
        },
        {
          "depth": 2,
          "text": "Acting on the map",
          "anchor": "acting-on-the-map"
        },
        {
          "depth": 2,
          "text": "Agent workflow and cleanup guarantees",
          "anchor": "agent-workflow-and-cleanup-guarantees"
        }
      ],
      "tokensEstimate": 3361,
      "frontmatter": {
        "title": "Workspace Map",
        "description": "Read-only terminal and JSON views of the current macOS workspace",
        "order": 5.5
      },
      "markdown": "---\ntitle: Workspace Map\ndescription: Read-only terminal and JSON views of the current macOS workspace\norder: 5.5\n---\n\n`lats map` is a read-only **drawing** of the current desktop. The structured\nread is `desktop.snapshot` (one call: frontmost, layer, displays, windows,\nsessions, permissions). Map prefers that payload, then falls back to joining\n`spaces.list` and `windows.list` on older app builds. It either draws a\nproportional terminal diagram or returns a versioned JSON projection.\n\nIt does **not** open, focus, move, resize, or capture a macOS window. Its box\ndrawing exists only in the invoking terminal. This is separate from the\ninteractive **Studio / Screen Map** window and the **Hyper+3 HUD**.\n\n## Terminal map\n\n```bash\nlats map\n```\n\nExample with two differently sized, vertically offset displays\n(`lats map --width 110 --height 14`):\n\n```text\nDesktop 7280×2395 @ 0,0 · 2 displays · 4 windows · 1 cell ≈ 92×184 pt\n\n╔═ D0 · AW3425DWM · Space 1 ═════════╗\n║                                    ╠═ D1 · U32J59x · Space 7 ════════════════╗\n║ ┌[2]┬[1] ChatGPT - ChatGPT───────┐ ║                                         ║\n║ │   │      │                     │ ║ ┌[3] Chrome────────┐                    ║\n║ │   │      │                     │ ║ │                  │                    ║\n║ │   │      │                     │ ║ │                  │    ┌[4]─┐          ║\n║ └───┴──────┘─────────────────────┘ ║ │                  │    │    │          ║\n║                                    ║ │                  │    │    │          ║\n╚════════════════════════════════════╣ │                  │    │    │          ║\n                                     ║ │                  │    │    │          ║\n                                     ║ │                  │    └────┘          ║\n                                     ║ └──────────────────┘                    ║\n                                     ║                                         ║\n                                     ╚═════════════════════════════════════════╝\n\nKey · ╔═ display ═╗   ┌[1] frontmost window ─┐   ┌[n]──┘ others, numbered only\n       lower n = nearer the front of its own display; n does not order across displays\n\nDisplays\n  D0  Display 0 · AW3425DWM · Space 1  frame 3440×1440 @ 0,0       usable 3440×1410 @ 0,30\n  D1  Display 1 · U32J59x · Space 7    frame 3840×2160 @ 3440,235  usable 3840×2130 @ 3440,265\n\nWindows · front to back within each display\n  1  D0  ChatGPT · ChatGPT      wid:114   2847×1410 @ 593,30\n  2  D0  iTerm · lattices       wid:208   1235×1255 @ 0,30\n  3  D1  Chrome · API docs      wid:3874  1920×2130 @ 3440,265\n  4  D1  Simulator · iPhone 16  wid:146   447×950 @ 5783,913\n```\n\nThe diagram uses each full display `frame` to build one global desktop viewport,\nso displays above, below, left, right, or offset from the primary display retain\ntheir real topology and relative dimensions. One uniform point-to-cell scale is\nchosen for the entire canvas; displays are never independently normalized.\nWindows use the display's usable `visibleFrame`, are clipped to that frame, and\nremain positioned in the same global coordinate system. Only on-screen windows\non each display's current Space appear. `windows.list` is ordered front to back,\nand the legend preserves that order: `[1]` is the frontmost window of its\ndisplay. Numbering runs continuously across displays, so `[4]` on D0 and `[5]`\non D1 have no z-relationship — depth is only meaningful within one display.\n\nTerminal cells are not square, so the projection compensates for a roughly 2:1\ncharacter aspect ratio. The map summary reports the approximate point size of\none terminal cell. Geometry and overlap are proportional but approximate.\n\n### The x-ray floor plan\n\nThe drawing has a deliberate reading order: **monitor bezel → labelled primary\nwindow → numbered secondary shapes → legend.**\n\n**The bezel** is a double-line outer frame with a blank gutter inside it, so a\nwindow border is never adjacent to the monitor frame — `║ ┌` rather than `║┌`,\nwhich reads as one thick border. Window painting also refuses outright any cell\nalready holding a double-line glyph, so the bezel cannot be merged into,\nreplaced, or masqueraded as, whatever the window geometry. On a display too\nsmall to afford the gutter the inset falls back to one cell; the refusal still\nholds, so windows never reach the bezel itself.\n\n**Windows** are single-line rectangles. The map is an **x-ray**: a window never\nfills or erases its interior, so a window that is completely covered on the real\ndesktop still appears as its own complete rectangle. Where two outlines cross\nthey merge into the correct box-drawing junction (`┼`, `┬`, `┤`) rather than one\nerasing the other. This trades the visual \"which window is in front\" cue — which\nthe legend carries authoritatively — for the guarantee that no window silently\ndisappears. There is no `(occluded)` state.\n\nSo that a rectangle still reads as closed inside all that merging, each window's\n**bottom-right endpoint is restamped as its own `┘` corner** after the outlines\nmerge. Two windows wanting the same cell produce the same glyph, so the result\nis order-independent; where a corner lands mid-edge, closure deliberately wins\nover line continuity.\n\n**Labels.** Exactly one window per display — the frontmost drawable one — spells\nout its app and title on the canvas. Every other window keeps its complete\ngeometry and its `[n]`, and its identity lives in the legend. Labelling every\noverlapping window is what turns a busy desktop into noise.\n\nMarkers are placed front to back. A window's number hugs its own top-left\ncorner and walks down its rows before it slides right, so a number always reads\nas belonging to the rectangle it sits inside. A marker may overwrite an outline;\nidentity wins over line continuity. Every legend row then resolves to exactly\none of four honest states:\n\n| Legend note | Meaning |\n|-------------|---------|\n| *(none)* | `[n]` is on the canvas |\n| `(small)` | The window had room for a bare `n` but not `[n]`. Numbers are never truncated — a bare marker also reserves a non-digit cell on each side so `10` and `12` cannot run together |\n| `(too small to label)` | The outline is drawn but no numeral fits anywhere inside it |\n| `(off-canvas)` | The window clipped away entirely and was not drawn |\n\nLabels inside the fixed-width canvas use a one-cell-safe projection; the legend\npreserves cleaned Unicode app names and titles and includes exact global\ngeometry.\n\nThe canvas still uses box-drawing characters, which are East Asian *Ambiguous*\nwidth. Under a CJK locale, or a terminal configured to render ambiguous-width\ncharacters as double width, the grid will shear. A pure-ASCII drawing mode is\nthe fix for that and is not implemented.\n\n## Options\n\n| Option | Effect |\n|--------|--------|\n| `--display <n>` | Include only the zero-based display index `n` |\n| `--width <n>` | Set the maximum canvas width; clamped to 32–120 columns |\n| `--height <n>` | Set the maximum canvas height; clamped to 8–40 rows |\n| `--json` | Emit the versioned current-Space snapshot instead of a diagram |\n| `-h`, `--help` | Print command help without contacting the daemon |\n\nWithout `--width`, the command uses the terminal width when available and 72\ncolumns otherwise. Without `--height`, the canvas is capped at 24 rows. The\ndesktop is scaled uniformly to fit within both maxima, so one bound can remain\npartly unused rather than distorting the topology. Numeric options must be\nnon-negative integers.\n\n## JSON for agents\n\n```bash\nlats map --json\nlats map --display 1 --json\n```\n\nThe JSON form is a canonical projection of the same current-Space view; it is\nnot the unfiltered response from `windows.list`:\n\n```json\n{\n  \"version\": 1,\n  \"coordinateSystem\": {\n    \"origin\": \"top-left\",\n    \"units\": \"points\",\n    \"reference\": \"global-desktop\"\n  },\n  \"displays\": [\n    {\n      \"displayIndex\": 0,\n      \"displayId\": \"37d8832a-2d66-02ca-b9f7-8f30a301b230\",\n      \"name\": \"Built-in Liquid Retina XDR Display\",\n      \"frame\": { \"x\": 0, \"y\": 0, \"w\": 1728, \"h\": 1117 },\n      \"visibleFrame\": { \"x\": 0, \"y\": 38, \"w\": 1728, \"h\": 1079 },\n      \"currentSpaceId\": 83,\n      \"spaces\": [\n        { \"id\": 83, \"index\": 1, \"display\": 0, \"isCurrent\": true }\n      ],\n      \"windows\": [\n        {\n          \"wid\": 4182,\n          \"app\": \"Ghostty\",\n          \"pid\": 916,\n          \"title\": \"lattices\",\n          \"frame\": { \"x\": 0, \"y\": 38, \"w\": 960, \"h\": 1038 },\n          \"spaceIds\": [83],\n          \"isOnScreen\": true,\n          \"axVerified\": true,\n          \"zIndex\": 0\n        }\n      ]\n    }\n  ]\n}\n```\n\nEach display nests only windows that are on-screen, belong to its current\nSpace, and intersect its usable frame. `zIndex: 0` is frontmost within that\ndisplay snapshot; it is per-display and zero-based, whereas the terminal\nlegend's `[n]` is continuous across displays and one-based. The two are not\ninterchangeable — join on `wid`, which is stable across both. `[n]` also\nrenumbers under `--display`, so a window the full map calls `[5]` becomes `[1]`\nin `map --display 1`. `--display` filters both terminal and JSON output. For every\nknown window, including hidden and off-Space windows, call `windows.list`\ndirectly instead. The versioned projection copies the documented display,\nSpace, and window fields explicitly rather than passing through arbitrary\nfuture daemon fields; the optional `latticesSession` value remains\navailable when `windows.list` supplies them.\n\nThe two daemon reads happen close together but are not an atomic WindowServer\ntransaction. A window or Space change during the calls can produce a briefly\nmixed snapshot; retry before a consequential mutation if the desktop is moving.\n\n## Daemon fields and coordinates\n\n`spaces.list` adds these fields to each display:\n\n| Field | Meaning |\n|-------|---------|\n| `name` | Human-readable `NSScreen.localizedName` |\n| `frame` | Full display bounds in global top-left coordinates |\n| `visibleFrame` | Usable bounds after the menu bar, Dock, and system reservations |\n\nThe display geometry uses the same coordinate system as `windows.list` window\nframes:\n\n- origin: top-left of the primary display;\n- units: macOS logical points, not capture pixels;\n- positive `x`: right;\n- positive `y`: down;\n- displays left of the primary display have negative `x`;\n- displays above the primary display have negative `y`;\n- displays below or to the right have offsets beyond the primary bounds.\n\nAppKit reports global screen rectangles from a bottom-left origin. Lattices\nconverts them with:\n\n```text\napiY = primaryDisplayHeight - (appKitY + height)\n```\n\nFor a 1117-point-high primary display, a 900-point display immediately above\nit starts at `y: -900`; a 700-point display immediately below it starts at\n`y: 1117`. Horizontal offsets are unchanged. Lattices matches SkyLight display\nrecords to `NSScreen` by display UUID, with the existing index as a fallback.\n\n## Surface and side-effect matrix\n\nA miniature workspace diagram on the desktop—often near the lower-left—is a\nHUD minimap, not terminal `lats map` output.\n\n| Primitive | CLI / daemon method | Read or mutate | Moves or focuses windows? | Visible desktop surface? | Lifetime and exact cleanup | Structured verification? |\n|-----------|---------------------|----------------|---------------------------|--------------------------|----------------------------|--------------------------|\n| Terminal workspace map     | `lats map`; `spaces.list` + `windows.list` | Read-only | No | No; terminal output only | Ends with the command; no desktop cleanup | **Yes, preferred**; use `--json` |\n| Raw inventory     | `lats windows --json`; `windows.list` | Read-only | No | No | One-shot | **Yes, preferred** |\n| Resolve or plan a target     | `lats call windows.resolve ...`; `windows.resolve` | Read-only | No | No | One-shot | **Yes, preferred** before mutations |\n| Studio / interactive Screen Map | No CLI/RPC open method; **Hyper+L** opens Studio | Opening is UI-only; Apply and explicit actions can mutate | Not merely by opening; Apply/focus/tile actions can | **Yes**, a normal Lattices window; preview mode can add an overlay | Hyper+L is an opener, not a close toggle. With map keyboard focus use **Escape** or **q**; the window close button also closes it and ends preview | No structured surface lifecycle API; do not open for verification |\n| HUD and HUD minimap | No CLI/RPC open method; **Hyper+3** toggles HUD | Opening is UI-only; chosen HUD actions can mutate | Selected actions can focus/tile | **Yes**, including the miniature map panels | Press **Hyper+3** while the HUD is active, or Escape from the base HUD. Tile/search submodes consume the first Escape, so another may be required. `M` cycles minimap hidden/docked/expanded. The Scattered and Full presets intentionally fade to ambient opacity instead of fully disappearing; while the HUD is active, use `X` (or `Option+X` from any HUD context) to select Classic, Glass, or Alive before dismissing when full teardown is required | No; agents should not open it for verification |\n| Live tab-stack chrome | `tabs.list`; `tabs.stack` / `layout` / `select` / `unstack` | List is read-only; the others mutate stack or window state | Create/layout can move windows; select focuses one | **Yes**, an enclosure and reserved rail independent of the HUD | Persists after Hyper+3 dismissal. Call `tabs.unstack` with its `id` to remove the stack without closing member windows | `tabs.list` is structured; never create a stack only to verify state |\n| Screenshot artifact         | `lats capture window [wid]`; `lats capture display [index]`; `capture.screenshotWindow` / `capture.screenshotDisplay` | Writes a run and PNG; display capture can also copy the PNG to the clipboard | No | Opens no picker or new Lattices surface. Display capture records the display as already composed, including visible Lattices overlays; macOS can show a permission prompt on first use | One-shot; the artifact persists in the run store, but no new Lattices UI remains | Yes when pixels are actually required; otherwise prefer state APIs |\n| Recording artifact         | `lats capture record ...`; `capture.recordWindow` / `capture.recordRegion` | Writes a run and MOV | No | No visible Lattices capture UI; an offscreen probe runs in the background | Until `--duration-ms` expires or `lats capture stop <run-id>` completes | Use only when temporal pixels are required |\n| Place a window         | `lats place ...`; `windows.place` / `actions.execute` | Mutating | **Yes** | No Lattices overview surface | One-shot; use the returned undo receipt / `history.undo` when applicable | Verify afterward with `lats map --json` or raw reads |\n\nThe Scattered and Full HUD presets intentionally leave a faint, non-interactive\nambient visual after dismissal. That is why Hyper+3 can appear to dismiss a\nminiature map without removing every pixel. Live tab-stack chrome is a separate\npersistent surface and must be removed with `tabs.unstack`, not Hyper+3.\nThe current API cannot enumerate or force-dismiss Lattices-owned transient\nHUD/Studio panels, so there is no honest structured proof that every such panel\nhas torn down. That lifecycle API is a follow-up, not something agents should\napproximate with screenshots.\n\n## Acting on the map\n\nEvery legend row carries the window's `wid`. That id feeds the movement\ncommands directly, without any visible Lattices surface:\n\n```bash\nlats window move 4182 --display 1                        # keep normalized frame\nlats window move 4182 --display 1 --placement right      # snap into a slot\nlats window place 4182 top-left                          # slot on current display\nlats window move 4182 --display 0 --dry-run --json       # plan without moving\n```\n\nBoth commands require an explicit wid — a malformed id is an error, never a\nfrontmost fallback — and return the daemon's execution receipt with before,\ntarget, and after frames. Verify afterward with `lats map --json`.\n\n## Agent workflow and cleanup guarantees\n\n1. Read `spaces.list`, `windows.list`, or `lats map --json`.\n2. Use `windows.resolve` or an `actions.execute` dry run when target identity or\n   placement matters.\n3. Mutate only through the explicit daemon action.\n4. Read structured state again and compare window ID, Space IDs, and frame.\n5. Capture pixels only when the result is inherently visual.\n\nAgents must not open Studio, the HUD/minimap, a capture/review UI, or another\nvisible Lattices surface when structured state is sufficient. `lats map`\nitself is always safe for this workflow because it cannot create macOS UI.\n\nIf an agent intentionally opens a visible Lattices surface, it must close that\nsame surface before finishing and verify the desktop through structured state\nwhere possible. `windows.list` can prove that target windows did not move, but\nit cannot currently prove HUD/Studio teardown. Report that gap instead of\nleaving a surface open or using a screenshot as a substitute for lifecycle\nstate.\n",
      "content": "`lats map` is a read-only **drawing** of the current desktop. The structured\nread is `desktop.snapshot` (one call: frontmost, layer, displays, windows,\nsessions, permissions). Map prefers that payload, then falls back to joining\n`spaces.list` and `windows.list` on older app builds. It either draws a\nproportional terminal diagram or returns a versioned JSON projection.\n\nIt does **not** open, focus, move, resize, or capture a macOS window. Its box\ndrawing exists only in the invoking terminal. This is separate from the\ninteractive **Studio / Screen Map** window and the **Hyper+3 HUD**.\n\n## Terminal map\n\n```bash\nlats map\n```\n\nExample with two differently sized, vertically offset displays\n(`lats map --width 110 --height 14`):\n\n```text\nDesktop 7280×2395 @ 0,0 · 2 displays · 4 windows · 1 cell ≈ 92×184 pt\n\n╔═ D0 · AW3425DWM · Space 1 ═════════╗\n║                                    ╠═ D1 · U32J59x · Space 7 ════════════════╗\n║ ┌[2]┬[1] ChatGPT - ChatGPT───────┐ ║                                         ║\n║ │   │      │                     │ ║ ┌[3] Chrome────────┐                    ║\n║ │   │      │                     │ ║ │                  │                    ║\n║ │   │      │                     │ ║ │                  │    ┌[4]─┐          ║\n║ └───┴──────┘─────────────────────┘ ║ │                  │    │    │          ║\n║                                    ║ │                  │    │    │          ║\n╚════════════════════════════════════╣ │                  │    │    │          ║\n                                     ║ │                  │    │    │          ║\n                                     ║ │                  │    └────┘          ║\n                                     ║ └──────────────────┘                    ║\n                                     ║                                         ║\n                                     ╚═════════════════════════════════════════╝\n\nKey · ╔═ display ═╗   ┌[1] frontmost window ─┐   ┌[n]──┘ others, numbered only\n       lower n = nearer the front of its own display; n does not order across displays\n\nDisplays\n  D0  Display 0 · AW3425DWM · Space 1  frame 3440×1440 @ 0,0       usable 3440×1410 @ 0,30\n  D1  Display 1 · U32J59x · Space 7    frame 3840×2160 @ 3440,235  usable 3840×2130 @ 3440,265\n\nWindows · front to back within each display\n  1  D0  ChatGPT · ChatGPT      wid:114   2847×1410 @ 593,30\n  2  D0  iTerm · lattices       wid:208   1235×1255 @ 0,30\n  3  D1  Chrome · API docs      wid:3874  1920×2130 @ 3440,265\n  4  D1  Simulator · iPhone 16  wid:146   447×950 @ 5783,913\n```\n\nThe diagram uses each full display `frame` to build one global desktop viewport,\nso displays above, below, left, right, or offset from the primary display retain\ntheir real topology and relative dimensions. One uniform point-to-cell scale is\nchosen for the entire canvas; displays are never independently normalized.\nWindows use the display's usable `visibleFrame`, are clipped to that frame, and\nremain positioned in the same global coordinate system. Only on-screen windows\non each display's current Space appear. `windows.list` is ordered front to back,\nand the legend preserves that order: `[1]` is the frontmost window of its\ndisplay. Numbering runs continuously across displays, so `[4]` on D0 and `[5]`\non D1 have no z-relationship — depth is only meaningful within one display.\n\nTerminal cells are not square, so the projection compensates for a roughly 2:1\ncharacter aspect ratio. The map summary reports the approximate point size of\none terminal cell. Geometry and overlap are proportional but approximate.\n\n### The x-ray floor plan\n\nThe drawing has a deliberate reading order: **monitor bezel → labelled primary\nwindow → numbered secondary shapes → legend.**\n\n**The bezel** is a double-line outer frame with a blank gutter inside it, so a\nwindow border is never adjacent to the monitor frame — `║ ┌` rather than `║┌`,\nwhich reads as one thick border. Window painting also refuses outright any cell\nalready holding a double-line glyph, so the bezel cannot be merged into,\nreplaced, or masqueraded as, whatever the window geometry. On a display too\nsmall to afford the gutter the inset falls back to one cell; the refusal still\nholds, so windows never reach the bezel itself.\n\n**Windows** are single-line rectangles. The map is an **x-ray**: a window never\nfills or erases its interior, so a window that is completely covered on the real\ndesktop still appears as its own complete rectangle. Where two outlines cross\nthey merge into the correct box-drawing junction (`┼`, `┬`, `┤`) rather than one\nerasing the other. This trades the visual \"which window is in front\" cue — which\nthe legend carries authoritatively — for the guarantee that no window silently\ndisappears. There is no `(occluded)` state.\n\nSo that a rectangle still reads as closed inside all that merging, each window's\n**bottom-right endpoint is restamped as its own `┘` corner** after the outlines\nmerge. Two windows wanting the same cell produce the same glyph, so the result\nis order-independent; where a corner lands mid-edge, closure deliberately wins\nover line continuity.\n\n**Labels.** Exactly one window per display — the frontmost drawable one — spells\nout its app and title on the canvas. Every other window keeps its complete\ngeometry and its `[n]`, and its identity lives in the legend. Labelling every\noverlapping window is what turns a busy desktop into noise.\n\nMarkers are placed front to back. A window's number hugs its own top-left\ncorner and walks down its rows before it slides right, so a number always reads\nas belonging to the rectangle it sits inside. A marker may overwrite an outline;\nidentity wins over line continuity. Every legend row then resolves to exactly\none of four honest states:\n\n| Legend note | Meaning |\n|-------------|---------|\n| *(none)* | `[n]` is on the canvas |\n| `(small)` | The window had room for a bare `n` but not `[n]`. Numbers are never truncated — a bare marker also reserves a non-digit cell on each side so `10` and `12` cannot run together |\n| `(too small to label)` | The outline is drawn but no numeral fits anywhere inside it |\n| `(off-canvas)` | The window clipped away entirely and was not drawn |\n\nLabels inside the fixed-width canvas use a one-cell-safe projection; the legend\npreserves cleaned Unicode app names and titles and includes exact global\ngeometry.\n\nThe canvas still uses box-drawing characters, which are East Asian *Ambiguous*\nwidth. Under a CJK locale, or a terminal configured to render ambiguous-width\ncharacters as double width, the grid will shear. A pure-ASCII drawing mode is\nthe fix for that and is not implemented.\n\n## Options\n\n| Option | Effect |\n|--------|--------|\n| `--display <n>` | Include only the zero-based display index `n` |\n| `--width <n>` | Set the maximum canvas width; clamped to 32–120 columns |\n| `--height <n>` | Set the maximum canvas height; clamped to 8–40 rows |\n| `--json` | Emit the versioned current-Space snapshot instead of a diagram |\n| `-h`, `--help` | Print command help without contacting the daemon |\n\nWithout `--width`, the command uses the terminal width when available and 72\ncolumns otherwise. Without `--height`, the canvas is capped at 24 rows. The\ndesktop is scaled uniformly to fit within both maxima, so one bound can remain\npartly unused rather than distorting the topology. Numeric options must be\nnon-negative integers.\n\n## JSON for agents\n\n```bash\nlats map --json\nlats map --display 1 --json\n```\n\nThe JSON form is a canonical projection of the same current-Space view; it is\nnot the unfiltered response from `windows.list`:\n\n```json\n{\n  \"version\": 1,\n  \"coordinateSystem\": {\n    \"origin\": \"top-left\",\n    \"units\": \"points\",\n    \"reference\": \"global-desktop\"\n  },\n  \"displays\": [\n    {\n      \"displayIndex\": 0,\n      \"displayId\": \"37d8832a-2d66-02ca-b9f7-8f30a301b230\",\n      \"name\": \"Built-in Liquid Retina XDR Display\",\n      \"frame\": { \"x\": 0, \"y\": 0, \"w\": 1728, \"h\": 1117 },\n      \"visibleFrame\": { \"x\": 0, \"y\": 38, \"w\": 1728, \"h\": 1079 },\n      \"currentSpaceId\": 83,\n      \"spaces\": [\n        { \"id\": 83, \"index\": 1, \"display\": 0, \"isCurrent\": true }\n      ],\n      \"windows\": [\n        {\n          \"wid\": 4182,\n          \"app\": \"Ghostty\",\n          \"pid\": 916,\n          \"title\": \"lattices\",\n          \"frame\": { \"x\": 0, \"y\": 38, \"w\": 960, \"h\": 1038 },\n          \"spaceIds\": [83],\n          \"isOnScreen\": true,\n          \"axVerified\": true,\n          \"zIndex\": 0\n        }\n      ]\n    }\n  ]\n}\n```\n\nEach display nests only windows that are on-screen, belong to its current\nSpace, and intersect its usable frame. `zIndex: 0` is frontmost within that\ndisplay snapshot; it is per-display and zero-based, whereas the terminal\nlegend's `[n]` is continuous across displays and one-based. The two are not\ninterchangeable — join on `wid`, which is stable across both. `[n]` also\nrenumbers under `--display`, so a window the full map calls `[5]` becomes `[1]`\nin `map --display 1`. `--display` filters both terminal and JSON output. For every\nknown window, including hidden and off-Space windows, call `windows.list`\ndirectly instead. The versioned projection copies the documented display,\nSpace, and window fields explicitly rather than passing through arbitrary\nfuture daemon fields; the optional `latticesSession` value remains\navailable when `windows.list` supplies them.\n\nThe two daemon reads happen close together but are not an atomic WindowServer\ntransaction. A window or Space change during the calls can produce a briefly\nmixed snapshot; retry before a consequential mutation if the desktop is moving.\n\n## Daemon fields and coordinates\n\n`spaces.list` adds these fields to each display:\n\n| Field | Meaning |\n|-------|---------|\n| `name` | Human-readable `NSScreen.localizedName` |\n| `frame` | Full display bounds in global top-left coordinates |\n| `visibleFrame` | Usable bounds after the menu bar, Dock, and system reservations |\n\nThe display geometry uses the same coordinate system as `windows.list` window\nframes:\n\n- origin: top-left of the primary display;\n- units: macOS logical points, not capture pixels;\n- positive `x`: right;\n- positive `y`: down;\n- displays left of the primary display have negative `x`;\n- displays above the primary display have negative `y`;\n- displays below or to the right have offsets beyond the primary bounds.\n\nAppKit reports global screen rectangles from a bottom-left origin. Lattices\nconverts them with:\n\n```text\napiY = primaryDisplayHeight - (appKitY + height)\n```\n\nFor a 1117-point-high primary display, a 900-point display immediately above\nit starts at `y: -900`; a 700-point display immediately below it starts at\n`y: 1117`. Horizontal offsets are unchanged. Lattices matches SkyLight display\nrecords to `NSScreen` by display UUID, with the existing index as a fallback.\n\n## Surface and side-effect matrix\n\nA miniature workspace diagram on the desktop—often near the lower-left—is a\nHUD minimap, not terminal `lats map` output.\n\n| Primitive | CLI / daemon method | Read or mutate | Moves or focuses windows? | Visible desktop surface? | Lifetime and exact cleanup | Structured verification? |\n|-----------|---------------------|----------------|---------------------------|--------------------------|----------------------------|--------------------------|\n| Terminal workspace map     | `lats map`; `spaces.list` + `windows.list` | Read-only | No | No; terminal output only | Ends with the command; no desktop cleanup | **Yes, preferred**; use `--json` |\n| Raw inventory     | `lats windows --json`; `windows.list` | Read-only | No | No | One-shot | **Yes, preferred** |\n| Resolve or plan a target     | `lats call windows.resolve ...`; `windows.resolve` | Read-only | No | No | One-shot | **Yes, preferred** before mutations |\n| Studio / interactive Screen Map | No CLI/RPC open method; **Hyper+L** opens Studio | Opening is UI-only; Apply and explicit actions can mutate | Not merely by opening; Apply/focus/tile actions can | **Yes**, a normal Lattices window; preview mode can add an overlay | Hyper+L is an opener, not a close toggle. With map keyboard focus use **Escape** or **q**; the window close button also closes it and ends preview | No structured surface lifecycle API; do not open for verification |\n| HUD and HUD minimap | No CLI/RPC open method; **Hyper+3** toggles HUD | Opening is UI-only; chosen HUD actions can mutate | Selected actions can focus/tile | **Yes**, including the miniature map panels | Press **Hyper+3** while the HUD is active, or Escape from the base HUD. Tile/search submodes consume the first Escape, so another may be required. `M` cycles minimap hidden/docked/expanded. The Scattered and Full presets intentionally fade to ambient opacity instead of fully disappearing; while the HUD is active, use `X` (or `Option+X` from any HUD context) to select Classic, Glass, or Alive before dismissing when full teardown is required | No; agents should not open it for verification |\n| Live tab-stack chrome | `tabs.list`; `tabs.stack` / `layout` / `select` / `unstack` | List is read-only; the others mutate stack or window state | Create/layout can move windows; select focuses one | **Yes**, an enclosure and reserved rail independent of the HUD | Persists after Hyper+3 dismissal. Call `tabs.unstack` with its `id` to remove the stack without closing member windows | `tabs.list` is structured; never create a stack only to verify state |\n| Screenshot artifact         | `lats capture window [wid]`; `lats capture display [index]`; `capture.screenshotWindow` / `capture.screenshotDisplay` | Writes a run and PNG; display capture can also copy the PNG to the clipboard | No | Opens no picker or new Lattices surface. Display capture records the display as already composed, including visible Lattices overlays; macOS can show a permission prompt on first use | One-shot; the artifact persists in the run store, but no new Lattices UI remains | Yes when pixels are actually required; otherwise prefer state APIs |\n| Recording artifact         | `lats capture record ...`; `capture.recordWindow` / `capture.recordRegion` | Writes a run and MOV | No | No visible Lattices capture UI; an offscreen probe runs in the background | Until `--duration-ms` expires or `lats capture stop <run-id>` completes | Use only when temporal pixels are required |\n| Place a window         | `lats place ...`; `windows.place` / `actions.execute` | Mutating | **Yes** | No Lattices overview surface | One-shot; use the returned undo receipt / `history.undo` when applicable | Verify afterward with `lats map --json` or raw reads |\n\nThe Scattered and Full HUD presets intentionally leave a faint, non-interactive\nambient visual after dismissal. That is why Hyper+3 can appear to dismiss a\nminiature map without removing every pixel. Live tab-stack chrome is a separate\npersistent surface and must be removed with `tabs.unstack`, not Hyper+3.\nThe current API cannot enumerate or force-dismiss Lattices-owned transient\nHUD/Studio panels, so there is no honest structured proof that every such panel\nhas torn down. That lifecycle API is a follow-up, not something agents should\napproximate with screenshots.\n\n## Acting on the map\n\nEvery legend row carries the window's `wid`. That id feeds the movement\ncommands directly, without any visible Lattices surface:\n\n```bash\nlats window move 4182 --display 1                        # keep normalized frame\nlats window move 4182 --display 1 --placement right      # snap into a slot\nlats window place 4182 top-left                          # slot on current display\nlats window move 4182 --display 0 --dry-run --json       # plan without moving\n```\n\nBoth commands require an explicit wid — a malformed id is an error, never a\nfrontmost fallback — and return the daemon's execution receipt with before,\ntarget, and after frames. Verify afterward with `lats map --json`.\n\n## Agent workflow and cleanup guarantees\n\n1. Read `spaces.list`, `windows.list`, or `lats map --json`.\n2. Use `windows.resolve` or an `actions.execute` dry run when target identity or\n   placement matters.\n3. Mutate only through the explicit daemon action.\n4. Read structured state again and compare window ID, Space IDs, and frame.\n5. Capture pixels only when the result is inherently visual.\n\nAgents must not open Studio, the HUD/minimap, a capture/review UI, or another\nvisible Lattices surface when structured state is sufficient. `lats map`\nitself is always safe for this workflow because it cannot create macOS UI.\n\nIf an agent intentionally opens a visible Lattices surface, it must close that\nsame surface before finishing and verify the desktop through structured state\nwhere possible. `windows.list` can prove that target windows did not move, but\nit cannot currently prove HUD/Studio teardown. Report that gap instead of\nleaving a surface open or using a screenshot as a substitute for lifecycle\nstate."
    },
    {
      "id": "lattices:tiling-reference",
      "slug": "tiling-reference",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Tiling Reference",
      "description": "Complete reference for Lattices window tiling — positions, grids, execution paths, and voice interpretation.",
      "order": null,
      "sourcePath": "docs/tiling-reference.md",
      "url": "/lattices/docs/tiling-reference",
      "rawUrl": "/lattices/docs/markdown/tiling-reference.md",
      "markdownUrl": "/lattices/docs/tiling-reference.md",
      "headings": [
        {
          "depth": 1,
          "text": "Tiling Reference",
          "anchor": "tiling-reference"
        },
        {
          "depth": 2,
          "text": "Position System",
          "anchor": "position-system"
        },
        {
          "depth": 3,
          "text": "Named Positions",
          "anchor": "named-positions"
        },
        {
          "depth": 3,
          "text": "Custom Grid Syntax",
          "anchor": "custom-grid-syntax"
        },
        {
          "depth": 3,
          "text": "App Shortcut Defaults",
          "anchor": "app-shortcut-defaults"
        },
        {
          "depth": 3,
          "text": "Placement Contract",
          "anchor": "placement-contract"
        },
        {
          "depth": 2,
          "text": "Drag Snap Zones",
          "anchor": "drag-snap-zones"
        },
        {
          "depth": 2,
          "text": "Execution Paths",
          "anchor": "execution-paths"
        },
        {
          "depth": 2,
          "text": "Frame Calculation",
          "anchor": "frame-calculation"
        },
        {
          "depth": 2,
          "text": "Window Targeting",
          "anchor": "window-targeting"
        },
        {
          "depth": 3,
          "text": "HandsOff-specific targeting",
          "anchor": "handsoff-specific-targeting"
        },
        {
          "depth": 2,
          "text": "Common Layouts (multi-action)",
          "anchor": "common-layouts-multi-action"
        },
        {
          "depth": 2,
          "text": "HandsOff Smart Distribution",
          "anchor": "handsoff-smart-distribution"
        },
        {
          "depth": 2,
          "text": "Guardrails",
          "anchor": "guardrails"
        },
        {
          "depth": 2,
          "text": "Current Gaps",
          "anchor": "current-gaps"
        }
      ],
      "tokensEstimate": 2198,
      "frontmatter": {},
      "markdown": "# Tiling Reference\n\nComplete reference for Lattices window tiling — positions, grids, execution paths, and voice interpretation.\n\n## Position System\n\nEvery tile position is a cell in a **cols × rows** grid, expressed as fractional `(x, y, w, h)` of the screen's visible area (excluding menu bar and dock).\n\n### Named Positions\n\nAll valid position strings that `TilePosition` accepts:\n\n| Position string | Grid | Cell (col, row) | Description |\n|---|---|---|---|\n| `maximize` | 1×1 | full | Full screen (100% × 100%) |\n| `center` | — | — | Centered floating (80% × 80%, offset 10%/10%) |\n| **Halves (2×1, full height)** | | | |\n| `left` | 2×1 | 0,0 | Left 50% |\n| `right` | 2×1 | 1,0 | Right 50% |\n| **Halves (1×2, full width)** | | | |\n| `top` | 1×2 | 0,0 | Top 50% |\n| `bottom` | 1×2 | 0,1 | Bottom 50% |\n| **Quarters (2×2)** | | | |\n| `top-left` | 2×2 | 0,0 | Top-left 25% |\n| `top-right` | 2×2 | 1,0 | Top-right 25% |\n| `bottom-left` | 2×2 | 0,1 | Bottom-left 25% |\n| `bottom-right` | 2×2 | 1,1 | Bottom-right 25% |\n| **Thirds (3×1, full height)** | | | |\n| `left-third` | 3×1 | 0,0 | Left 33% column |\n| `center-third` | 3×1 | 1,0 | Center 33% column |\n| `right-third` | 3×1 | 2,0 | Right 33% column |\n| **Sixths (3×2)** | | | |\n| `top-left-third` | 3×2 | 0,0 | Top-left sixth |\n| `top-center-third` | 3×2 | 1,0 | Top-center sixth |\n| `top-right-third` | 3×2 | 2,0 | Top-right sixth |\n| `bottom-left-third` | 3×2 | 0,1 | Bottom-left sixth |\n| `bottom-center-third` | 3×2 | 1,1 | Bottom-center sixth |\n| `bottom-right-third` | 3×2 | 2,1 | Bottom-right sixth |\n| **Fourths (4×1, full height)** | | | |\n| `first-fourth` | 4×1 | 0,0 | Leftmost 25% column |\n| `second-fourth` | 4×1 | 1,0 | Second 25% column |\n| `third-fourth` | 4×1 | 2,0 | Third 25% column |\n| `last-fourth` | 4×1 | 3,0 | Rightmost 25% column |\n| **Eighths (4×2)** | | | |\n| `top-first-fourth` | 4×2 | 0,0 | Top row, 1st column |\n| `top-second-fourth` | 4×2 | 1,0 | Top row, 2nd column |\n| `top-third-fourth` | 4×2 | 2,0 | Top row, 3rd column |\n| `top-last-fourth` | 4×2 | 3,0 | Top row, 4th column |\n| `bottom-first-fourth` | 4×2 | 0,1 | Bottom row, 1st column |\n| `bottom-second-fourth` | 4×2 | 1,1 | Bottom row, 2nd column |\n| `bottom-third-fourth` | 4×2 | 2,1 | Bottom row, 3rd column |\n| `bottom-last-fourth` | 4×2 | 3,1 | Bottom row, 4th column |\n| **Horizontal thirds (1×3)** | | | |\n| `top-third` | 1×3 | 0,0 | Top 33% row |\n| `middle-third` | 1×3 | 0,1 | Middle 33% row |\n| `bottom-third` | 1×3 | 0,2 | Bottom 33% row |\n| **Edge quarters** | | | |\n| `left-quarter` | 4×1 | 0,0 | Leftmost 25% column |\n| `right-quarter` | 4×1 | 3,0 | Rightmost 25% column |\n| `top-quarter` | 1×4 | 0,0 | Top 25% row |\n| `bottom-quarter` | 1×4 | 0,3 | Bottom 25% row |\n\n### Custom Grid Syntax\n\nFor arbitrary grids: compact `CxR:C,R` or canonical `grid:CxR:C,R`\n\n- `C` = total columns, `R` = total rows\n- Compact `CxR:C,R` starts at 1 from the top-left\n- Canonical `grid:CxR:C,R` starts at 0 for API/wire compatibility\n- Example: `5x3:3,2` or `grid:5x3:2,1` = center cell of a 5×3 grid\n- Spans can use two inclusive corners, such as `4x4:1,1-2,2`\n\nParsed by `PlacementSpec` / `parseGridString()` into fractional `(x, y, w, h)`.\n\n### App Shortcut Defaults\n\nThe menu bar app exposes tiling shortcuts in Settings > Shortcuts. Useful\ndefaults include:\n\n- `Ctrl+Option+G`: show the 4x4 grid placement target for the frontmost window.\n- `Ctrl+Option+V`: move the frontmost window into the least-occupied cell of a\n  3x2 grid on its current screen.\n- `Ctrl+Option+Space`: open the command bar; type `/tile 3x2:3,2` or another\n  placement string to place the captured frontmost window precisely.\n- `Ctrl+Option` + mouse: a small HUD sits at the cursor. Stay in the inner\n  center and release to cancel; move a little (still in the center cell) for\n  maximize; move into a sector for that tile. Release Ctrl+Option to apply.\n  Settings → Input Controls can switch Loop ring vs Lattices 3×3 matrix. A\n  Ctrl+Option key chord still wins if you press one during the hold.\n  Command-drag snap keeps large edge/corner drop zones.\n\n### Placement Contract\n\nPlacement strings are convenient at the boundary, but the daemon uses a\ntyped placement model internally:\n\n- named tile positions\n- arbitrary grid cells\n- raw fractional rectangles\n\nThat is what keeps CLI, daemon, voice, and hands-off execution aligned.\n\n## Drag Snap Zones\n\nThe menu bar app can also use placement specs as drag-to-snap targets.\nWhen you drag a window, Lattices shows faint landing zones plus a live\npreview of the resulting frame. Releasing over a zone tiles the dragged\nwindow to that placement. Hold `Command` while dragging to reveal snap\nmode, and release `Command` to drop back to a normal free drag without\nending the gesture.\n\nThe recommended agent-owned config lives in `~/.lattices/snap-zones.json`:\n\n```json\n{\n  \"enabled\": true,\n  \"modifier\": \"command\",\n  \"zoneOpacity\": 0.08,\n  \"highlightOpacity\": 0.18,\n  \"previewOpacity\": 0.14,\n  \"rules\": [\n    {\n      \"id\": \"left-edge\",\n      \"label\": \"Left\",\n      \"placement\": \"left\",\n      \"trigger\": { \"x\": 0.0, \"y\": 0.18, \"w\": 0.12, \"h\": 0.64 },\n      \"priority\": 10\n    },\n    {\n      \"id\": \"notes-rail\",\n      \"label\": \"Notes\",\n      \"placement\": { \"x\": 0.68, \"y\": 0.0, \"w\": 0.32, \"h\": 1.0 },\n      \"trigger\": { \"x\": 0.88, \"y\": 0.18, \"w\": 0.12, \"h\": 0.64 },\n      \"priority\": 30\n    }\n  ]\n}\n```\n\nNotes:\n\n- `rules` is the preferred list key. `zones` is still accepted for backward compatibility.\n- `modifier` accepts `command`, `option`, `control`, or `shift`.\n- `placement` can be a named placement/preset string or raw fractions.\n- `trigger` uses normalized `(x, y, w, h)` fractions of the screen's\n  visible area, with `y = 0` at the top.\n- `priority` breaks ties when trigger regions overlap.\n- `trigger` can also be a named placement or preset string if you want\n  the trigger region itself to reuse an existing tile definition.\n- The older `~/.lattices/grid.json` `snapZones` section still works, but\n  `~/.lattices/snap-zones.json` is the cleaner file for agents to edit.\n\n## Execution Paths\n\nThe old split-brain tiling logic has been collapsed toward a shared path.\nThe canonical mutation is now:\n\n```json\n{ \"method\": \"windows.place\", \"params\": { \"placement\": \"left\" } }\n```\n\nAll higher-level surfaces should compile into the same placement model:\n\n- **Daemon / CLI**: `windows.place` is the canonical mutation\n- **Compatibility**: `window.tile` maps to `windows.place`\n- **Voice / hands-off**: parse natural language, then emit a placement spec\n- **HUD**: still exposes a smaller shortcut set, but should target the same placement executor\n\nThe important change is that placement resolution now happens through\n`PlacementSpec`, not through separate ad hoc parsers per surface.\n\n## Frame Calculation\n\nAll paths eventually call one of:\n\n1. **`WindowTiler.tileFrame(for:on:)`** — takes a `TilePosition` + `NSScreen`, returns a `CGRect` in AX coordinates (origin = top-left of primary display)\n2. **`WindowTiler.tileFrame(fractions:inDisplay:)`** — takes raw `(x, y, w, h)` fractions + display rect\n\nThe math:\n```\nvisible = screen.visibleFrame  (excludes menu bar + dock)\nprimaryH = primary screen height\naxTop = primaryH - visible.maxY  (flip from AppKit bottom-left to AX top-left)\n\nframe.x = visible.x + visible.width × fx\nframe.y = axTop + visible.height × fy\nframe.w = visible.width × fw\nframe.h = visible.height × fh\n```\n\n## Window Targeting\n\nThe `tile_window` intent resolves the target window in this priority:\n\n1. **`session`** slot → `LatticesApi.window.place` / `window.tile` compatibility wrapper\n2. **`wid`** slot → `DesktopModel.shared.windows[wid]` (direct window ID lookup)\n3. **`app`** slot → first matching window by `localizedCaseInsensitiveContains`, excluding recently-tiled windows (prevents double-matching in batch commands like \"Chrome left, Chrome right\")\n4. **No target** → tiles the frontmost window\n\n### HandsOff-specific targeting\n\nThe system prompt instructs the LLM to always use `wid` from the desktop snapshot, never `app`. This avoids ambiguity when multiple windows of the same app exist. In speech, the LLM says the app name; in the JSON action, it uses the wid.\n\n## Common Layouts (multi-action)\n\nThese are composed from multiple `tile_window` actions:\n\n| Layout | Actions |\n|---|---|\n| Split screen | left + right |\n| Stack | top + bottom |\n| Thirds | left-third + center-third + right-third |\n| Quadrants | top-left + top-right + bottom-left + bottom-right |\n| Six-up (3×2) | All six `*-*-third` positions |\n| Eight-up (4×2) | All eight `*-*-fourth` positions |\n| Distribute | Single `distribute` intent (auto-grid) |\n\nCLI shortcuts compile into the same distributor:\n\n- `lats tile family` → smart-grid the frontmost app's visible windows\n- `lats distribute iTerm2 right` → smart-grid visible iTerm windows inside the right half\n\n## HandsOff Smart Distribution\n\nWhen the LLM sends multiple `tile_window` actions targeting the **same position**, `HandsOffSession.distributeTileActions()` subdivides:\n\n- 2+ windows → \"left\" becomes top-left, left, bottom-left\n- 2+ windows → \"right\" becomes top-right, right, bottom-right\n- 2+ windows → \"maximize\" fans out to quadrants then halves\n\n## Guardrails\n\n- **Typed placement validation**: invalid placement strings or objects are rejected at the daemon boundary.\n- **Recently-tiled dedup**: `IntentEngine.recentlyTiledWids` prevents the same window from being matched twice within 2 seconds during batch operations.\n- **Compatibility wrappers**: `window.tile` still works, but routes through the same placement machinery.\n\n## Current Gaps\n\n1. **Voice extraction still needs to catch up**: the canonical executor understands horizontal thirds and edge quarters, but the local voice resolver still needs broader phrase coverage.\n2. **HUD coverage is narrower than the executor**: keyboard tiling exposes a small subset of the full placement vocabulary.\n3. **Optimization and layer actions are still wrapper-level**: `spaces.optimize` and `layers.activate` are now stable action IDs, but they currently wrap existing distributor and layer-switching behavior rather than a full planner.\n",
      "content": "# Tiling Reference\n\nComplete reference for Lattices window tiling — positions, grids, execution paths, and voice interpretation.\n\n## Position System\n\nEvery tile position is a cell in a **cols × rows** grid, expressed as fractional `(x, y, w, h)` of the screen's visible area (excluding menu bar and dock).\n\n### Named Positions\n\nAll valid position strings that `TilePosition` accepts:\n\n| Position string | Grid | Cell (col, row) | Description |\n|---|---|---|---|\n| `maximize` | 1×1 | full | Full screen (100% × 100%) |\n| `center` | — | — | Centered floating (80% × 80%, offset 10%/10%) |\n| **Halves (2×1, full height)** | | | |\n| `left` | 2×1 | 0,0 | Left 50% |\n| `right` | 2×1 | 1,0 | Right 50% |\n| **Halves (1×2, full width)** | | | |\n| `top` | 1×2 | 0,0 | Top 50% |\n| `bottom` | 1×2 | 0,1 | Bottom 50% |\n| **Quarters (2×2)** | | | |\n| `top-left` | 2×2 | 0,0 | Top-left 25% |\n| `top-right` | 2×2 | 1,0 | Top-right 25% |\n| `bottom-left` | 2×2 | 0,1 | Bottom-left 25% |\n| `bottom-right` | 2×2 | 1,1 | Bottom-right 25% |\n| **Thirds (3×1, full height)** | | | |\n| `left-third` | 3×1 | 0,0 | Left 33% column |\n| `center-third` | 3×1 | 1,0 | Center 33% column |\n| `right-third` | 3×1 | 2,0 | Right 33% column |\n| **Sixths (3×2)** | | | |\n| `top-left-third` | 3×2 | 0,0 | Top-left sixth |\n| `top-center-third` | 3×2 | 1,0 | Top-center sixth |\n| `top-right-third` | 3×2 | 2,0 | Top-right sixth |\n| `bottom-left-third` | 3×2 | 0,1 | Bottom-left sixth |\n| `bottom-center-third` | 3×2 | 1,1 | Bottom-center sixth |\n| `bottom-right-third` | 3×2 | 2,1 | Bottom-right sixth |\n| **Fourths (4×1, full height)** | | | |\n| `first-fourth` | 4×1 | 0,0 | Leftmost 25% column |\n| `second-fourth` | 4×1 | 1,0 | Second 25% column |\n| `third-fourth` | 4×1 | 2,0 | Third 25% column |\n| `last-fourth` | 4×1 | 3,0 | Rightmost 25% column |\n| **Eighths (4×2)** | | | |\n| `top-first-fourth` | 4×2 | 0,0 | Top row, 1st column |\n| `top-second-fourth` | 4×2 | 1,0 | Top row, 2nd column |\n| `top-third-fourth` | 4×2 | 2,0 | Top row, 3rd column |\n| `top-last-fourth` | 4×2 | 3,0 | Top row, 4th column |\n| `bottom-first-fourth` | 4×2 | 0,1 | Bottom row, 1st column |\n| `bottom-second-fourth` | 4×2 | 1,1 | Bottom row, 2nd column |\n| `bottom-third-fourth` | 4×2 | 2,1 | Bottom row, 3rd column |\n| `bottom-last-fourth` | 4×2 | 3,1 | Bottom row, 4th column |\n| **Horizontal thirds (1×3)** | | | |\n| `top-third` | 1×3 | 0,0 | Top 33% row |\n| `middle-third` | 1×3 | 0,1 | Middle 33% row |\n| `bottom-third` | 1×3 | 0,2 | Bottom 33% row |\n| **Edge quarters** | | | |\n| `left-quarter` | 4×1 | 0,0 | Leftmost 25% column |\n| `right-quarter` | 4×1 | 3,0 | Rightmost 25% column |\n| `top-quarter` | 1×4 | 0,0 | Top 25% row |\n| `bottom-quarter` | 1×4 | 0,3 | Bottom 25% row |\n\n### Custom Grid Syntax\n\nFor arbitrary grids: compact `CxR:C,R` or canonical `grid:CxR:C,R`\n\n- `C` = total columns, `R` = total rows\n- Compact `CxR:C,R` starts at 1 from the top-left\n- Canonical `grid:CxR:C,R` starts at 0 for API/wire compatibility\n- Example: `5x3:3,2` or `grid:5x3:2,1` = center cell of a 5×3 grid\n- Spans can use two inclusive corners, such as `4x4:1,1-2,2`\n\nParsed by `PlacementSpec` / `parseGridString()` into fractional `(x, y, w, h)`.\n\n### App Shortcut Defaults\n\nThe menu bar app exposes tiling shortcuts in Settings > Shortcuts. Useful\ndefaults include:\n\n- `Ctrl+Option+G`: show the 4x4 grid placement target for the frontmost window.\n- `Ctrl+Option+V`: move the frontmost window into the least-occupied cell of a\n  3x2 grid on its current screen.\n- `Ctrl+Option+Space`: open the command bar; type `/tile 3x2:3,2` or another\n  placement string to place the captured frontmost window precisely.\n- `Ctrl+Option` + mouse: a small HUD sits at the cursor. Stay in the inner\n  center and release to cancel; move a little (still in the center cell) for\n  maximize; move into a sector for that tile. Release Ctrl+Option to apply.\n  Settings → Input Controls can switch Loop ring vs Lattices 3×3 matrix. A\n  Ctrl+Option key chord still wins if you press one during the hold.\n  Command-drag snap keeps large edge/corner drop zones.\n\n### Placement Contract\n\nPlacement strings are convenient at the boundary, but the daemon uses a\ntyped placement model internally:\n\n- named tile positions\n- arbitrary grid cells\n- raw fractional rectangles\n\nThat is what keeps CLI, daemon, voice, and hands-off execution aligned.\n\n## Drag Snap Zones\n\nThe menu bar app can also use placement specs as drag-to-snap targets.\nWhen you drag a window, Lattices shows faint landing zones plus a live\npreview of the resulting frame. Releasing over a zone tiles the dragged\nwindow to that placement. Hold `Command` while dragging to reveal snap\nmode, and release `Command` to drop back to a normal free drag without\nending the gesture.\n\nThe recommended agent-owned config lives in `~/.lattices/snap-zones.json`:\n\n```json\n{\n  \"enabled\": true,\n  \"modifier\": \"command\",\n  \"zoneOpacity\": 0.08,\n  \"highlightOpacity\": 0.18,\n  \"previewOpacity\": 0.14,\n  \"rules\": [\n    {\n      \"id\": \"left-edge\",\n      \"label\": \"Left\",\n      \"placement\": \"left\",\n      \"trigger\": { \"x\": 0.0, \"y\": 0.18, \"w\": 0.12, \"h\": 0.64 },\n      \"priority\": 10\n    },\n    {\n      \"id\": \"notes-rail\",\n      \"label\": \"Notes\",\n      \"placement\": { \"x\": 0.68, \"y\": 0.0, \"w\": 0.32, \"h\": 1.0 },\n      \"trigger\": { \"x\": 0.88, \"y\": 0.18, \"w\": 0.12, \"h\": 0.64 },\n      \"priority\": 30\n    }\n  ]\n}\n```\n\nNotes:\n\n- `rules` is the preferred list key. `zones` is still accepted for backward compatibility.\n- `modifier` accepts `command`, `option`, `control`, or `shift`.\n- `placement` can be a named placement/preset string or raw fractions.\n- `trigger` uses normalized `(x, y, w, h)` fractions of the screen's\n  visible area, with `y = 0` at the top.\n- `priority` breaks ties when trigger regions overlap.\n- `trigger` can also be a named placement or preset string if you want\n  the trigger region itself to reuse an existing tile definition.\n- The older `~/.lattices/grid.json` `snapZones` section still works, but\n  `~/.lattices/snap-zones.json` is the cleaner file for agents to edit.\n\n## Execution Paths\n\nThe old split-brain tiling logic has been collapsed toward a shared path.\nThe canonical mutation is now:\n\n```json\n{ \"method\": \"windows.place\", \"params\": { \"placement\": \"left\" } }\n```\n\nAll higher-level surfaces should compile into the same placement model:\n\n- **Daemon / CLI**: `windows.place` is the canonical mutation\n- **Compatibility**: `window.tile` maps to `windows.place`\n- **Voice / hands-off**: parse natural language, then emit a placement spec\n- **HUD**: still exposes a smaller shortcut set, but should target the same placement executor\n\nThe important change is that placement resolution now happens through\n`PlacementSpec`, not through separate ad hoc parsers per surface.\n\n## Frame Calculation\n\nAll paths eventually call one of:\n\n1. **`WindowTiler.tileFrame(for:on:)`** — takes a `TilePosition` + `NSScreen`, returns a `CGRect` in AX coordinates (origin = top-left of primary display)\n2. **`WindowTiler.tileFrame(fractions:inDisplay:)`** — takes raw `(x, y, w, h)` fractions + display rect\n\nThe math:\n```\nvisible = screen.visibleFrame  (excludes menu bar + dock)\nprimaryH = primary screen height\naxTop = primaryH - visible.maxY  (flip from AppKit bottom-left to AX top-left)\n\nframe.x = visible.x + visible.width × fx\nframe.y = axTop + visible.height × fy\nframe.w = visible.width × fw\nframe.h = visible.height × fh\n```\n\n## Window Targeting\n\nThe `tile_window` intent resolves the target window in this priority:\n\n1. **`session`** slot → `LatticesApi.window.place` / `window.tile` compatibility wrapper\n2. **`wid`** slot → `DesktopModel.shared.windows[wid]` (direct window ID lookup)\n3. **`app`** slot → first matching window by `localizedCaseInsensitiveContains`, excluding recently-tiled windows (prevents double-matching in batch commands like \"Chrome left, Chrome right\")\n4. **No target** → tiles the frontmost window\n\n### HandsOff-specific targeting\n\nThe system prompt instructs the LLM to always use `wid` from the desktop snapshot, never `app`. This avoids ambiguity when multiple windows of the same app exist. In speech, the LLM says the app name; in the JSON action, it uses the wid.\n\n## Common Layouts (multi-action)\n\nThese are composed from multiple `tile_window` actions:\n\n| Layout | Actions |\n|---|---|\n| Split screen | left + right |\n| Stack | top + bottom |\n| Thirds | left-third + center-third + right-third |\n| Quadrants | top-left + top-right + bottom-left + bottom-right |\n| Six-up (3×2) | All six `*-*-third` positions |\n| Eight-up (4×2) | All eight `*-*-fourth` positions |\n| Distribute | Single `distribute` intent (auto-grid) |\n\nCLI shortcuts compile into the same distributor:\n\n- `lats tile family` → smart-grid the frontmost app's visible windows\n- `lats distribute iTerm2 right` → smart-grid visible iTerm windows inside the right half\n\n## HandsOff Smart Distribution\n\nWhen the LLM sends multiple `tile_window` actions targeting the **same position**, `HandsOffSession.distributeTileActions()` subdivides:\n\n- 2+ windows → \"left\" becomes top-left, left, bottom-left\n- 2+ windows → \"right\" becomes top-right, right, bottom-right\n- 2+ windows → \"maximize\" fans out to quadrants then halves\n\n## Guardrails\n\n- **Typed placement validation**: invalid placement strings or objects are rejected at the daemon boundary.\n- **Recently-tiled dedup**: `IntentEngine.recentlyTiledWids` prevents the same window from being matched twice within 2 seconds during batch operations.\n- **Compatibility wrappers**: `window.tile` still works, but routes through the same placement machinery.\n\n## Current Gaps\n\n1. **Voice extraction still needs to catch up**: the canonical executor understands horizontal thirds and edge quarters, but the local voice resolver still needs broader phrase coverage.\n2. **HUD coverage is narrower than the executor**: keyboard tiling exposes a small subset of the full placement vocabulary.\n3. **Optimization and layer actions are still wrapper-level**: `spaces.optimize` and `layers.activate` are now stable action IDs, but they currently wrap existing distributor and layer-switching behavior rather than a full planner."
    },
    {
      "id": "lattices:mouse-gestures",
      "slug": "mouse-gestures",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Mouse Gestures",
      "description": "Hold a mouse button, draw a shape or direction, release — runs matched actions like a window manager power-user shortcut system.",
      "order": 4.2,
      "sourcePath": "docs/mouse-gestures.md",
      "url": "/lattices/docs/mouse-gestures",
      "rawUrl": "/lattices/docs/markdown/mouse-gestures.md",
      "markdownUrl": "/lattices/docs/mouse-gestures.md",
      "headings": [
        {
          "depth": 1,
          "text": "Mouse Gestures",
          "anchor": "mouse-gestures"
        },
        {
          "depth": 2,
          "text": "Concept",
          "anchor": "concept"
        },
        {
          "depth": 2,
          "text": "Config Ownership",
          "anchor": "config-ownership"
        },
        {
          "depth": 2,
          "text": "Lightweight History",
          "anchor": "lightweight-history"
        },
        {
          "depth": 2,
          "text": "Button Names",
          "anchor": "button-names"
        },
        {
          "depth": 2,
          "text": "Trigger Kinds",
          "anchor": "trigger-kinds"
        },
        {
          "depth": 2,
          "text": "Default: Dictation Gesture",
          "anchor": "default-dictation-gesture"
        },
        {
          "depth": 2,
          "text": "Default: Screenshot Area Gesture",
          "anchor": "default-screenshot-area-gesture"
        },
        {
          "depth": 2,
          "text": "Actions",
          "anchor": "actions"
        },
        {
          "depth": 2,
          "text": "Example: Enter Gesture",
          "anchor": "example-enter-gesture"
        },
        {
          "depth": 2,
          "text": "Visuals",
          "anchor": "visuals"
        },
        {
          "depth": 2,
          "text": "Implementation",
          "anchor": "implementation"
        },
        {
          "depth": 2,
          "text": "Safety",
          "anchor": "safety"
        }
      ],
      "tokensEstimate": 1381,
      "frontmatter": {
        "title": "Mouse Gestures",
        "description": "Hold a mouse button, draw a shape or direction, release — runs matched actions like a window manager power-user shortcut system.",
        "order": 4.2
      },
      "markdown": "---\ntitle: Mouse Gestures\ndescription: Hold a mouse button, draw a shape or direction, release — runs matched actions like a window manager power-user shortcut system.\norder: 4.2\n---\n\n# Mouse Gestures\n\n## Concept\n\nMouse gestures are a user-level shortcut system for the macOS app. Hold a\nconfigured mouse button, draw a direction or shape, then release to run the\nmatched action.\n\nThe app code owns the recognizer, action dispatcher, and JSON schema. The\ngesture mappings are data, so agents can define or change hotkeys without\nadding a named action in Swift. Your actual gesture mappings live in:\n\n```bash\n~/.lattices/mouse-shortcuts.json\n```\n\nThat file is machine-local preference data. It is not project config, and it\nshould not be committed to this repository unless you are intentionally adding\nan example fixture or changing the schema.\n\n## Config Ownership\n\nThere are two layers:\n\n- **Code defaults** in `MouseGestureConfig.swift` provide a minimal starter\n  config when no user file exists.\n- **User rules** in `~/.lattices/mouse-shortcuts.json` are the source of truth\n  after the file has been created.\n\nDo not add personal shortcuts by changing `MouseGestureConfig.swift`. Add them\nto the user JSON file instead. The Settings UI can open that file from\n**Settings -> Shortcuts -> Mouse Gestures -> Configure...**.\n\nAgents should usually write these rules through the daemon:\n\n```js\nawait daemonCall('mouse.shortcuts.upsert', { rule })\n```\n\nThe daemon persists the rule, snapshots the old config, and refreshes the\nrunning app immediately. If a tool edits `~/.lattices/mouse-shortcuts.json`\ndirectly, call `mouse.shortcuts.reload` afterward; no app restart is required.\n\n## Lightweight History\n\nLattices keeps local snapshots of your mouse shortcut config before risky\nchanges:\n\n```bash\n~/.lattices/mouse-shortcuts.history/\n```\n\nSnapshots are written before external edits are reloaded and before Settings\nrestores defaults or restores a previous snapshot. The newest 40 snapshots are\nkept. Use **History** in Settings to open the folder, or **Undo Last** to\nrestore the latest snapshot.\n\n## Button Names\n\nThe config accepts these common button names:\n\n| Config value | Meaning |\n|---|---|\n| `middle` | Middle button / wheel click |\n| `back` | Back side button, often mouse button 4 |\n| `forward` | Forward side button, often mouse button 5 |\n| `right` | Right mouse button |\n| `buttonN` | Explicit numbered button fallback |\n\nNormal clicks pass through when a configured button is only being watched for\ndrag or shape gestures. Once movement crosses the gesture threshold and matches\na real gesture, Lattices claims the interaction.\n\nBrowser frontmost apps are bypassed so native middle-click, Back, and Forward\nbutton behavior stays intact. This avoids half-claimed side-button gestures\naccidentally navigating while you are drawing a shape.\n\n## Trigger Kinds\n\nRules match one of three trigger kinds:\n\n| Kind | Required fields | Example |\n|---|---|---|\n| `click` | `button` | Middle click sends paste |\n| `drag` | `button`, `direction` | Middle drag left switches Space |\n| `shape` | `button`, `shape` | Back-button L shape activates iTerm |\n\nDirections are `up`, `down`, `left`, and `right`.\n\nUseful shapes include:\n\n| Shape | Motion |\n|---|---|\n| `l-shape-down-right` | Down, then right |\n| `l-shape-down-left` | Down, then left |\n| `l-shape-up-right` | Up, then right |\n| `l-shape-up-left` | Up, then left |\n| `reverse-l-right-down` | Right, then down |\n| `reverse-l-left-down` | Left, then down |\n| `v-shape` | Down, then up |\n| `reverse-v` | Up, then down |\n| `circle` | A loose closed loop |\n\nThe circle recognizer is intentionally generous: it looks for broad circular\nmotion, a reasonably closed path, and enough turn coverage rather than a\ngeometrically perfect circle.\n\n## Default: Dictation Gesture\n\nNew default configs use a plain shortcut action for dictation. The middle-button\nup gesture sends the Lattices voice command hotkey, currently Hyper+D:\n\n```json\n{\n  \"id\": \"dictation\",\n  \"enabled\": true,\n  \"device\": \"any\",\n  \"trigger\": {\n    \"button\": \"middle\",\n    \"kind\": \"drag\",\n    \"direction\": \"up\"\n  },\n  \"action\": {\n    \"type\": \"shortcut.send\",\n    \"shortcut\": {\n      \"key\": \"d\",\n      \"keyCode\": 2,\n      \"modifiers\": [\"control\", \"option\", \"shift\", \"command\"]\n    }\n  }\n}\n```\n\n## Default: Screenshot Area Gesture\n\nNew default configs include a back-button circle gesture for the macOS\nscreenshot area picker (`Command` + `Shift` + `4`):\n\n```json\n{\n  \"id\": \"back-circle-screenshot-area\",\n  \"enabled\": true,\n  \"device\": \"any\",\n  \"trigger\": {\n    \"button\": \"back\",\n    \"kind\": \"shape\",\n    \"shape\": \"circle\"\n  },\n  \"action\": {\n    \"type\": \"shortcut.send\",\n    \"shortcut\": {\n      \"key\": \"4\",\n      \"keyCode\": 21,\n      \"modifiers\": [\"command\", \"shift\"]\n    }\n  },\n  \"visual\": {\n    \"renderer\": \"matrix\",\n    \"theme\": \"lattices\"\n  }\n}\n```\n\n## Actions\n\nSupported action types include:\n\n| Type | Purpose |\n|---|---|\n| `space.previous` | Switch to the previous macOS Space |\n| `space.next` | Switch to the next macOS Space |\n| `screenmap.toggle` | Open the Screen Map overview |\n| `dictation.start` | Legacy compatibility alias that presses the configured Voice Command hotkey |\n| `shortcut.send` | Send a keyboard shortcut |\n| `app.activate` | Activate an app by name |\n\n## Example: Enter Gesture\n\nThis is a user-level rule, not a code default. Add it to\n`~/.lattices/mouse-shortcuts.json` if you want the back button plus a quick\ndown-then-left shape to press Enter:\n\n```json\n{\n  \"id\": \"back-down-left-enter\",\n  \"enabled\": true,\n  \"device\": \"any\",\n  \"trigger\": {\n    \"button\": \"back\",\n    \"kind\": \"shape\",\n    \"shape\": \"l-shape-down-left\"\n  },\n  \"action\": {\n    \"type\": \"shortcut.send\",\n    \"shortcut\": {\n      \"key\": \"enter\",\n      \"keyCode\": 36,\n      \"modifiers\": []\n    }\n  }\n}\n```\n\n## Visuals\n\nRules may include an optional `visual` block for feedback. Visuals are\ndecorative: they must never decide whether a gesture matches or whether an\naction runs. If a visual asset is missing or slow, the gesture should still\nexecute through the native action path.\n\nShape gestures can opt into native matrix completion feedback with\n`\"visual\": { \"renderer\": \"matrix\" }`. When enabled, Lattices smooths the\ncaptured path, replays it briefly inside a small 3x3 rounded-cell matrix, then\nsnaps into a compact confirmation glyph and action label. This is feedback\nonly; the matched rule has already dispatched.\n\n## Implementation\n\n- `apps/mac/Sources/Core/Input/MouseGestureController.swift` owns the CGEvent\n  tap, gesture session state, passthrough behavior, and action dispatch.\n- `apps/mac/Sources/Core/Input/MouseGestureConfig.swift` defines the Codable\n  schema and initial fallback defaults.\n- `apps/mac/Sources/Core/Input/MouseShortcutStore.swift` loads the user-level\n  JSON file and provides thread-safe snapshots to the event tap.\n- `apps/mac/Sources/Core/Input/ShapeRecognizer.swift` converts raw gesture\n  paths into direction and shape labels.\n\n## Safety\n\nThe controller installs a session-wide CGEvent tap. To avoid blocking the\nsystem input pipeline:\n\n- The tap callback does only cheap work and dispatches heavier work async.\n- A circuit breaker handles OS tap timeout events and pauses gestures when\n  needed.\n- Short, unmatched clicks are replayed or passed through so native app behavior\n  remains intact.\n- The emergency reset chord clears stuck input capture state.\n",
      "content": "# Mouse Gestures\n\n## Concept\n\nMouse gestures are a user-level shortcut system for the macOS app. Hold a\nconfigured mouse button, draw a direction or shape, then release to run the\nmatched action.\n\nThe app code owns the recognizer, action dispatcher, and JSON schema. The\ngesture mappings are data, so agents can define or change hotkeys without\nadding a named action in Swift. Your actual gesture mappings live in:\n\n```bash\n~/.lattices/mouse-shortcuts.json\n```\n\nThat file is machine-local preference data. It is not project config, and it\nshould not be committed to this repository unless you are intentionally adding\nan example fixture or changing the schema.\n\n## Config Ownership\n\nThere are two layers:\n\n- **Code defaults** in `MouseGestureConfig.swift` provide a minimal starter\n  config when no user file exists.\n- **User rules** in `~/.lattices/mouse-shortcuts.json` are the source of truth\n  after the file has been created.\n\nDo not add personal shortcuts by changing `MouseGestureConfig.swift`. Add them\nto the user JSON file instead. The Settings UI can open that file from\n**Settings -> Shortcuts -> Mouse Gestures -> Configure...**.\n\nAgents should usually write these rules through the daemon:\n\n```js\nawait daemonCall('mouse.shortcuts.upsert', { rule })\n```\n\nThe daemon persists the rule, snapshots the old config, and refreshes the\nrunning app immediately. If a tool edits `~/.lattices/mouse-shortcuts.json`\ndirectly, call `mouse.shortcuts.reload` afterward; no app restart is required.\n\n## Lightweight History\n\nLattices keeps local snapshots of your mouse shortcut config before risky\nchanges:\n\n```bash\n~/.lattices/mouse-shortcuts.history/\n```\n\nSnapshots are written before external edits are reloaded and before Settings\nrestores defaults or restores a previous snapshot. The newest 40 snapshots are\nkept. Use **History** in Settings to open the folder, or **Undo Last** to\nrestore the latest snapshot.\n\n## Button Names\n\nThe config accepts these common button names:\n\n| Config value | Meaning |\n|---|---|\n| `middle` | Middle button / wheel click |\n| `back` | Back side button, often mouse button 4 |\n| `forward` | Forward side button, often mouse button 5 |\n| `right` | Right mouse button |\n| `buttonN` | Explicit numbered button fallback |\n\nNormal clicks pass through when a configured button is only being watched for\ndrag or shape gestures. Once movement crosses the gesture threshold and matches\na real gesture, Lattices claims the interaction.\n\nBrowser frontmost apps are bypassed so native middle-click, Back, and Forward\nbutton behavior stays intact. This avoids half-claimed side-button gestures\naccidentally navigating while you are drawing a shape.\n\n## Trigger Kinds\n\nRules match one of three trigger kinds:\n\n| Kind | Required fields | Example |\n|---|---|---|\n| `click` | `button` | Middle click sends paste |\n| `drag` | `button`, `direction` | Middle drag left switches Space |\n| `shape` | `button`, `shape` | Back-button L shape activates iTerm |\n\nDirections are `up`, `down`, `left`, and `right`.\n\nUseful shapes include:\n\n| Shape | Motion |\n|---|---|\n| `l-shape-down-right` | Down, then right |\n| `l-shape-down-left` | Down, then left |\n| `l-shape-up-right` | Up, then right |\n| `l-shape-up-left` | Up, then left |\n| `reverse-l-right-down` | Right, then down |\n| `reverse-l-left-down` | Left, then down |\n| `v-shape` | Down, then up |\n| `reverse-v` | Up, then down |\n| `circle` | A loose closed loop |\n\nThe circle recognizer is intentionally generous: it looks for broad circular\nmotion, a reasonably closed path, and enough turn coverage rather than a\ngeometrically perfect circle.\n\n## Default: Dictation Gesture\n\nNew default configs use a plain shortcut action for dictation. The middle-button\nup gesture sends the Lattices voice command hotkey, currently Hyper+D:\n\n```json\n{\n  \"id\": \"dictation\",\n  \"enabled\": true,\n  \"device\": \"any\",\n  \"trigger\": {\n    \"button\": \"middle\",\n    \"kind\": \"drag\",\n    \"direction\": \"up\"\n  },\n  \"action\": {\n    \"type\": \"shortcut.send\",\n    \"shortcut\": {\n      \"key\": \"d\",\n      \"keyCode\": 2,\n      \"modifiers\": [\"control\", \"option\", \"shift\", \"command\"]\n    }\n  }\n}\n```\n\n## Default: Screenshot Area Gesture\n\nNew default configs include a back-button circle gesture for the macOS\nscreenshot area picker (`Command` + `Shift` + `4`):\n\n```json\n{\n  \"id\": \"back-circle-screenshot-area\",\n  \"enabled\": true,\n  \"device\": \"any\",\n  \"trigger\": {\n    \"button\": \"back\",\n    \"kind\": \"shape\",\n    \"shape\": \"circle\"\n  },\n  \"action\": {\n    \"type\": \"shortcut.send\",\n    \"shortcut\": {\n      \"key\": \"4\",\n      \"keyCode\": 21,\n      \"modifiers\": [\"command\", \"shift\"]\n    }\n  },\n  \"visual\": {\n    \"renderer\": \"matrix\",\n    \"theme\": \"lattices\"\n  }\n}\n```\n\n## Actions\n\nSupported action types include:\n\n| Type | Purpose |\n|---|---|\n| `space.previous` | Switch to the previous macOS Space |\n| `space.next` | Switch to the next macOS Space |\n| `screenmap.toggle` | Open the Screen Map overview |\n| `dictation.start` | Legacy compatibility alias that presses the configured Voice Command hotkey |\n| `shortcut.send` | Send a keyboard shortcut |\n| `app.activate` | Activate an app by name |\n\n## Example: Enter Gesture\n\nThis is a user-level rule, not a code default. Add it to\n`~/.lattices/mouse-shortcuts.json` if you want the back button plus a quick\ndown-then-left shape to press Enter:\n\n```json\n{\n  \"id\": \"back-down-left-enter\",\n  \"enabled\": true,\n  \"device\": \"any\",\n  \"trigger\": {\n    \"button\": \"back\",\n    \"kind\": \"shape\",\n    \"shape\": \"l-shape-down-left\"\n  },\n  \"action\": {\n    \"type\": \"shortcut.send\",\n    \"shortcut\": {\n      \"key\": \"enter\",\n      \"keyCode\": 36,\n      \"modifiers\": []\n    }\n  }\n}\n```\n\n## Visuals\n\nRules may include an optional `visual` block for feedback. Visuals are\ndecorative: they must never decide whether a gesture matches or whether an\naction runs. If a visual asset is missing or slow, the gesture should still\nexecute through the native action path.\n\nShape gestures can opt into native matrix completion feedback with\n`\"visual\": { \"renderer\": \"matrix\" }`. When enabled, Lattices smooths the\ncaptured path, replays it briefly inside a small 3x3 rounded-cell matrix, then\nsnaps into a compact confirmation glyph and action label. This is feedback\nonly; the matched rule has already dispatched.\n\n## Implementation\n\n- `apps/mac/Sources/Core/Input/MouseGestureController.swift` owns the CGEvent\n  tap, gesture session state, passthrough behavior, and action dispatch.\n- `apps/mac/Sources/Core/Input/MouseGestureConfig.swift` defines the Codable\n  schema and initial fallback defaults.\n- `apps/mac/Sources/Core/Input/MouseShortcutStore.swift` loads the user-level\n  JSON file and provides thread-safe snapshots to the event tap.\n- `apps/mac/Sources/Core/Input/ShapeRecognizer.swift` converts raw gesture\n  paths into direction and shape labels.\n\n## Safety\n\nThe controller installs a session-wide CGEvent tap. To avoid blocking the\nsystem input pipeline:\n\n- The tap callback does only cheap work and dispatches heavier work async.\n- A circuit breaker handles OS tap timeout events and pauses gestures when\n  needed.\n- Short, unmatched clicks are replayed or passed through so native app behavior\n  remains intact.\n- The emergency reset chord clears stuck input capture state."
    },
    {
      "id": "lattices:ocr",
      "slug": "ocr",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Screen OCR & Search",
      "description": "Vision-powered screen reading with full-text search for agents",
      "order": 3.5,
      "sourcePath": "docs/ocr.md",
      "url": "/lattices/docs/ocr",
      "rawUrl": "/lattices/docs/markdown/ocr.md",
      "markdownUrl": "/lattices/docs/ocr.md",
      "headings": [
        {
          "depth": 2,
          "text": "Enabling OCR",
          "anchor": "enabling-ocr"
        },
        {
          "depth": 3,
          "text": "Accuracy modes",
          "anchor": "accuracy-modes"
        },
        {
          "depth": 2,
          "text": "How scanning works",
          "anchor": "how-scanning-works"
        },
        {
          "depth": 3,
          "text": "Change detection",
          "anchor": "change-detection"
        },
        {
          "depth": 2,
          "text": "Browsing results",
          "anchor": "browsing-results"
        },
        {
          "depth": 2,
          "text": "Searching",
          "anchor": "searching"
        },
        {
          "depth": 3,
          "text": "From the command palette",
          "anchor": "from-the-command-palette"
        },
        {
          "depth": 3,
          "text": "From the CLI",
          "anchor": "from-the-cli"
        },
        {
          "depth": 3,
          "text": "From the agent API",
          "anchor": "from-the-agent-api"
        },
        {
          "depth": 4,
          "text": "ocr.snapshot",
          "anchor": "ocr-snapshot"
        },
        {
          "depth": 4,
          "text": "ocr.search",
          "anchor": "ocr-search"
        },
        {
          "depth": 4,
          "text": "ocr.history",
          "anchor": "ocr-history"
        },
        {
          "depth": 4,
          "text": "ocr.scan",
          "anchor": "ocr-scan"
        },
        {
          "depth": 2,
          "text": "Storage",
          "anchor": "storage"
        },
        {
          "depth": 2,
          "text": "Agent usage",
          "anchor": "agent-usage"
        },
        {
          "depth": 2,
          "text": "Requirements",
          "anchor": "requirements"
        }
      ],
      "tokensEstimate": 1187,
      "frontmatter": {
        "title": "Screen OCR & Search",
        "description": "Vision-powered screen reading with full-text search for agents",
        "order": 3.5
      },
      "markdown": "---\ntitle: Screen OCR & Search\ndescription: Vision-powered screen reading with full-text search for agents\norder: 3.5\n---\n\nThe menu bar app reads text from visible windows using Apple's Vision\nframework and keeps the latest results in memory, so `windows.search`,\nthe command palette and Omni search match windows by what they show.\nAgents can use this to \"see\" what's on screen.\n\nBundle builds also store every scan in a local SQLite database with FTS5\nfull-text search. `ocr.search` and `ocr.history` read that\nhistory; the free build doesn't have them.\n\n## Enabling OCR\n\nOpen **Settings** (via command palette or gear icon) and toggle\n**Search & OCR** on. OCR is disabled by default.\n\n### Accuracy modes\n\n| Mode | Description |\n|------|-------------|\n| **Accurate** (default) | Higher quality recognition, slower processing |\n| **Fast** | Lower latency, reduced accuracy |\n\nBoth modes use `VNRecognizeTextRequest` with language correction enabled.\n\n## How scanning works\n\nThe app runs two scan schedules:\n\n| Schedule | Interval | Window limit | Purpose |\n|----------|----------|-------------|---------|\n| **Quick scan** | 60 seconds | Top 5 windows | Keep recent content fresh |\n| **Deep scan** | 2 hours | Up to 15 windows | Catch less-active windows |\n\nBoth intervals and limits are configurable in Settings.\n\n### Change detection\n\nBefore running OCR on a window, the app captures the window image and\ncomputes a SHA-256 hash of the pixel data. If the hash matches the\nprevious scan, the cached result is reused. No Vision processing needed.\n\nThis keeps CPU usage low when windows haven't changed. A 100ms throttle\nbetween windows further limits processing bursts.\n\n## Browsing results\n\nThe **Recent Captures** section in Settings shows OCR results grouped by\napp. Each entry displays the window title, recognized text preview, and\ntimestamp.\n\n## Searching\n\n### From the command palette\n\nThe OmniSearch bar (Control+Option+Space) searches OCR content alongside windows,\nprojects, and sessions. Matches show as \"Screen Text\" results with\ncontextual snippets.\n\n### From the CLI\n\n```bash\n# Search windows by title, app, session, and OCR content\nlats search \"error\"\n\n# Deep search — also inspects terminal tabs and processes\nlats search \"myproject\" --deep\n\n# Same as --deep (all search sources)\nlats search \"myproject\" --all\n\n# Search + focus + tile the top result\nlats place \"myproject\" left\n\n# View current OCR snapshot\nlats scan\n\n# Search OCR history directly (FTS5 syntax)\nlats scan search \"error OR failed\"\n\n# Trigger an immediate deep scan\nlats scan deep\n\n# View OCR history for a specific window ID\nlats scan history 12345\n```\n\n### From the agent API\n\nAgents can query OCR data through four API methods:\n\n| Method | Description |\n|--------|-------------|\n| `ocr.snapshot` | Current OCR results for all visible windows |\n| `ocr.search` | Full-text search across history (FTS5 syntax) |\n| `ocr.history` | Timeline of OCR results for one window, or all windows without `wid` |\n| `ocr.scan` | Trigger an immediate deep scan |\n\n#### `ocr.snapshot`\n\nReturns the latest OCR results for all on-screen windows.\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\nconst snapshot = await daemonCall('ocr.snapshot')\n// [{ wid, app, title, frame, fullText, blocks, timestamp }, ...]\n```\n\nEach result includes:\n- `wid` — window ID\n- `app` — application name\n- `title` — window title\n- `frame` — `{ x, y, w, h }` screen position\n- `fullText` — all recognized text concatenated\n- `blocks` — individual text blocks with `{ text, confidence, x, y, w, h }`\n- `timestamp` — Unix timestamp of the scan\n\n#### `ocr.search`\n\nFull-text search across OCR history using FTS5 query syntax.\n\n```js\nconst results = await daemonCall('ocr.search', {\n  query: 'error OR failed',  // FTS5 query (required)\n  app: 'Terminal',            // filter by app name (optional)\n  limit: 50,                  // max results (optional, default 50)\n  live: false                 // search in-memory snapshot instead of history (optional)\n})\n// [{ id, wid, app, title, frame, fullText, snippet, timestamp }, ...]\n```\n\nThe `snippet` field contains FTS5-highlighted text with `«` and `»`\ndelimiters around matched terms.\n\n#### `ocr.history`\n\nGet the OCR content timeline for a specific window, or across every window\nwhen `wid` is omitted.\n\n```js\nconst history = await daemonCall('ocr.history', {\n  wid: 12345,  // window ID (optional; omit for every window)\n  limit: 50    // max results (optional, default 50)\n})\n```\n\n#### `ocr.scan`\n\nTrigger an immediate deep scan (all visible windows up to the deep limit).\n\n```js\nawait daemonCall('ocr.scan')\n// { ok: true }\n```\n\n## Storage\n\nOCR data is stored in `~/.lattices/ocr.db`, a SQLite database in WAL\n(Write-Ahead Logging) mode for safe concurrent reads.\n\nThe schema uses two tables:\n- `ocr_entry` — stores window ID, app, title, frame, full text, and timestamp\n- `ocr_fts` — FTS5 virtual table indexing `full_text`, `app`, and `title`\n\nTriggers keep the FTS index in sync with inserts, updates, and deletes.\n\nEntries older than 3 days are automatically deleted.\n\n## Agent usage\n\nA typical agent workflow: trigger a scan, then search for relevant content.\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Trigger a fresh scan\nawait daemonCall('ocr.scan')\n\n// Search for compilation errors across all windows\nconst errors = await daemonCall('ocr.search', { query: 'error OR warning' })\n\nfor (const result of errors) {\n  console.log(`[${result.app}] ${result.title}`)\n  console.log(result.snippet)\n}\n\n// Read everything currently visible\nconst snapshot = await daemonCall('ocr.snapshot')\nfor (const win of snapshot) {\n  console.log(`${win.app}: ${win.fullText.slice(0, 200)}`)\n}\n```\n\n## Requirements\n\n- **Screen Recording** permission — required to capture window images\n- Grant via System Settings > Privacy & Security > Screen Recording\n- Add the lattices menu bar app to the allowed list\n",
      "content": "The menu bar app reads text from visible windows using Apple's Vision\nframework and keeps the latest results in memory, so `windows.search`,\nthe command palette and Omni search match windows by what they show.\nAgents can use this to \"see\" what's on screen.\n\nBundle builds also store every scan in a local SQLite database with FTS5\nfull-text search. `ocr.search` and `ocr.history` read that\nhistory; the free build doesn't have them.\n\n## Enabling OCR\n\nOpen **Settings** (via command palette or gear icon) and toggle\n**Search & OCR** on. OCR is disabled by default.\n\n### Accuracy modes\n\n| Mode | Description |\n|------|-------------|\n| **Accurate** (default) | Higher quality recognition, slower processing |\n| **Fast** | Lower latency, reduced accuracy |\n\nBoth modes use `VNRecognizeTextRequest` with language correction enabled.\n\n## How scanning works\n\nThe app runs two scan schedules:\n\n| Schedule | Interval | Window limit | Purpose |\n|----------|----------|-------------|---------|\n| **Quick scan** | 60 seconds | Top 5 windows | Keep recent content fresh |\n| **Deep scan** | 2 hours | Up to 15 windows | Catch less-active windows |\n\nBoth intervals and limits are configurable in Settings.\n\n### Change detection\n\nBefore running OCR on a window, the app captures the window image and\ncomputes a SHA-256 hash of the pixel data. If the hash matches the\nprevious scan, the cached result is reused. No Vision processing needed.\n\nThis keeps CPU usage low when windows haven't changed. A 100ms throttle\nbetween windows further limits processing bursts.\n\n## Browsing results\n\nThe **Recent Captures** section in Settings shows OCR results grouped by\napp. Each entry displays the window title, recognized text preview, and\ntimestamp.\n\n## Searching\n\n### From the command palette\n\nThe OmniSearch bar (Control+Option+Space) searches OCR content alongside windows,\nprojects, and sessions. Matches show as \"Screen Text\" results with\ncontextual snippets.\n\n### From the CLI\n\n```bash\n# Search windows by title, app, session, and OCR content\nlats search \"error\"\n\n# Deep search — also inspects terminal tabs and processes\nlats search \"myproject\" --deep\n\n# Same as --deep (all search sources)\nlats search \"myproject\" --all\n\n# Search + focus + tile the top result\nlats place \"myproject\" left\n\n# View current OCR snapshot\nlats scan\n\n# Search OCR history directly (FTS5 syntax)\nlats scan search \"error OR failed\"\n\n# Trigger an immediate deep scan\nlats scan deep\n\n# View OCR history for a specific window ID\nlats scan history 12345\n```\n\n### From the agent API\n\nAgents can query OCR data through four API methods:\n\n| Method | Description |\n|--------|-------------|\n| `ocr.snapshot` | Current OCR results for all visible windows |\n| `ocr.search` | Full-text search across history (FTS5 syntax) |\n| `ocr.history` | Timeline of OCR results for one window, or all windows without `wid` |\n| `ocr.scan` | Trigger an immediate deep scan |\n\n#### `ocr.snapshot`\n\nReturns the latest OCR results for all on-screen windows.\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\nconst snapshot = await daemonCall('ocr.snapshot')\n// [{ wid, app, title, frame, fullText, blocks, timestamp }, ...]\n```\n\nEach result includes:\n- `wid` — window ID\n- `app` — application name\n- `title` — window title\n- `frame` — `{ x, y, w, h }` screen position\n- `fullText` — all recognized text concatenated\n- `blocks` — individual text blocks with `{ text, confidence, x, y, w, h }`\n- `timestamp` — Unix timestamp of the scan\n\n#### `ocr.search`\n\nFull-text search across OCR history using FTS5 query syntax.\n\n```js\nconst results = await daemonCall('ocr.search', {\n  query: 'error OR failed',  // FTS5 query (required)\n  app: 'Terminal',            // filter by app name (optional)\n  limit: 50,                  // max results (optional, default 50)\n  live: false                 // search in-memory snapshot instead of history (optional)\n})\n// [{ id, wid, app, title, frame, fullText, snippet, timestamp }, ...]\n```\n\nThe `snippet` field contains FTS5-highlighted text with `«` and `»`\ndelimiters around matched terms.\n\n#### `ocr.history`\n\nGet the OCR content timeline for a specific window, or across every window\nwhen `wid` is omitted.\n\n```js\nconst history = await daemonCall('ocr.history', {\n  wid: 12345,  // window ID (optional; omit for every window)\n  limit: 50    // max results (optional, default 50)\n})\n```\n\n#### `ocr.scan`\n\nTrigger an immediate deep scan (all visible windows up to the deep limit).\n\n```js\nawait daemonCall('ocr.scan')\n// { ok: true }\n```\n\n## Storage\n\nOCR data is stored in `~/.lattices/ocr.db`, a SQLite database in WAL\n(Write-Ahead Logging) mode for safe concurrent reads.\n\nThe schema uses two tables:\n- `ocr_entry` — stores window ID, app, title, frame, full text, and timestamp\n- `ocr_fts` — FTS5 virtual table indexing `full_text`, `app`, and `title`\n\nTriggers keep the FTS index in sync with inserts, updates, and deletes.\n\nEntries older than 3 days are automatically deleted.\n\n## Agent usage\n\nA typical agent workflow: trigger a scan, then search for relevant content.\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Trigger a fresh scan\nawait daemonCall('ocr.scan')\n\n// Search for compilation errors across all windows\nconst errors = await daemonCall('ocr.search', { query: 'error OR warning' })\n\nfor (const result of errors) {\n  console.log(`[${result.app}] ${result.title}`)\n  console.log(result.snippet)\n}\n\n// Read everything currently visible\nconst snapshot = await daemonCall('ocr.snapshot')\nfor (const win of snapshot) {\n  console.log(`${win.app}: ${win.fullText.slice(0, 200)}`)\n}\n```\n\n## Requirements\n\n- **Screen Recording** permission — required to capture window images\n- Grant via System Settings > Privacy & Security > Screen Recording\n- Add the lattices menu bar app to the allowed list"
    },
    {
      "id": "lattices:visit",
      "slug": "visit",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Visiting cursor",
      "description": "A visit lends one machine's mouse and keyboard to another without moving the\nother machine's own cursor. Push the pointer past the edge that faces a paired\nhost and it parks there, frozen. From then on your mouse drives ",
      "order": null,
      "sourcePath": "docs/visit.md",
      "url": "/lattices/docs/visit",
      "rawUrl": "/lattices/docs/markdown/visit.md",
      "markdownUrl": "/lattices/docs/visit.md",
      "headings": [
        {
          "depth": 1,
          "text": "Visiting cursor",
          "anchor": "visiting-cursor"
        },
        {
          "depth": 2,
          "text": "Channel",
          "anchor": "channel"
        },
        {
          "depth": 3,
          "text": "Up (visiting device → host)",
          "anchor": "up-visiting-device-host"
        },
        {
          "depth": 3,
          "text": "Down (host → visiting device)",
          "anchor": "down-host-visiting-device"
        },
        {
          "depth": 2,
          "text": "On the host",
          "anchor": "on-the-host"
        },
        {
          "depth": 3,
          "text": "Ending a visit from the host",
          "anchor": "ending-a-visit-from-the-host"
        },
        {
          "depth": 2,
          "text": "On the Mac",
          "anchor": "on-the-mac"
        },
        {
          "depth": 2,
          "text": "Machines",
          "anchor": "machines"
        }
      ],
      "tokensEstimate": 1901,
      "frontmatter": {},
      "markdown": "# Visiting cursor\n\nA visit lends one machine's mouse and keyboard to another without moving the\nother machine's own cursor. Push the pointer past the edge that faces a paired\nhost and it parks there, frozen. From then on your mouse drives a coral\n**visitor** on the host, labelled with your machine's name, the way a screen\nshare shows a second cursor. Your keys type on the host. Nothing on the host\nasks for a new permission: the visit rides the companion bridge's pairing, and\nneeds the `input.trackpad` capability that pairing already grants.\n\nA visit ends when:\n\n- the visitor goes back off the edge it came in by;\n- you press **⌃⌥⌘ Home**, or pick Bring Cursor Home from the menu bar;\n- someone at the host runs `lattices-host mouse-home` (see [Ending a visit from the host](#ending-a-visit-from-the-host));\n- the channel drops, or either side stops hearing from the other for 6 seconds.\n\nYour own cursor never left, so ending a visit can't strand it.\n\n## Channel\n\n`GET /visit` on the gateway (port 9399 by default) upgrades to a WebSocket. The upgrade\nrequest carries the same signed headers as every protected bridge request\n(`X-Lattices-Device-Id`, `-Timestamp`, `-Nonce`, `-Signature`), signed over\nmethod `GET`, path `/visit` and an empty body. The host checks them, and that\nthe device holds `input.trackpad`, before upgrading. Otherwise it answers\n401 or 403 as for any other route.\n\nIn Lattices 0.15.1, the companion bridge listens on a private\nsocket, `~/.lattices/run/companion.sock`, and no longer opens port 5287 or\nadvertises over Bonjour. The visiting machine reaches `/visit` through the\nhost's tailnet gateway (`lats gateway start --tailnet`), on the gateway port\n(9399 by default), with the same signed headers. Configure the gateway address\nexplicitly. The gateway checks tailnet identity; the companion still checks\ndevice pairing, signatures and `input.trackpad`. The gateway relays encrypted\nvisit frames without decrypting them. Companion device trust stays in the\ncompanion's store; approving a gateway RPC client does not grant a visit.\n\nEvery frame after that is a **binary** message: a ChaChaPoly sealed box in its\ncombined form (12-byte nonce, ciphertext, 16-byte tag). There's no base64. The\nkey is the device's encryption key, the one bridge bodies already use. The AAD\nis the UTF-8 of:\n\n```\nvisit\\n<direction>\\n<device id>\\n<upgrade nonce>\\n<seq>\n```\n\n`direction` is `up` from the visiting device and `down` from the host. `seq`\nstarts at 0 in each direction and goes up by one per frame. A frame that\ndoesn't open, or arrives out of sequence, closes the socket with code 1008.\n\nThe plaintext is one JSON object with a type in `t`.\n\n### Up (visiting device → host)\n\n| `t` | Fields | Meaning |\n| --- | --- | --- |\n| `enter` | `name`, `edge`, `at` | Start a visit. `edge` is the side of the host's screens the visitor comes in by (`left`, `right`, `top`, `bottom`). `at` (0–1) is the position along that edge. `name` labels the visitor. |\n| `move` | `dx`, `dy` | Relative motion in the device's points. |\n| `button` | `button`, `down` | `left`, `right` or `middle`, pressed or released. |\n| `scroll` | `dx`, `dy` | Pixels. Positive `dy` scrolls down, positive `dx` scrolls right. |\n| `key` | `key`, `mods` | One keystroke. `key` is an XKB keysym name (`Return`, `a`, `Left`, `F5`); `mods` lists any of `ctrl`, `shift`, `alt`, `super`. The Mac sends ⌘ as `super`. |\n| `text` | `text` | Characters to type as they are. |\n| `ping` | | Sent every 2 seconds. |\n| `leave` | | End the visit. |\n\n### Down (host → visiting device)\n\n| `t` | Fields | Meaning |\n| --- | --- | --- |\n| `ready` | `x`, `y` | The visitor is drawn at this host position. |\n| `exit` | `edge`, `at` | The visitor went back off `edge` at `at` (0–1). The device ends the visit and puts its own cursor at the matching point of its edge. |\n| `pong` | | Answers `ping`. |\n| `error` | `message` | Something failed. The visit goes on unless the socket closes. |\n\n## On the host\n\n- **Area.** The visitor moves over the host's real screens. Virtual outputs, such\n  as Lattices' own `LATS-*`, are left out, the same set Bring Cursor Home picks\n  from. The edge in `enter` is the edge of that area.\n- **Two cursors.** The host's own cursor stays where it is while the visitor\n  moves. For a button press or a scroll, the real pointer goes to the visitor so\n  the input lands there. Keyboard focus must end up where the visitor clicked.\n  When the visit ends, the host's cursor goes back to where it was. A visit\n  that never clicked or scrolled never moved it, so the host leaves it alone.\n- **Release.** If the visit ends while a button is held, the host releases it.\n\n### Ending a visit from the host\n\n`lattices-host mouse-home` ends every visit on this machine, then brings the\nhost's own cursor home as it always has. Each running host listens on a unix\nsocket, `$XDG_RUNTIME_DIR/lattices-host/visit-<bridge port>.sock` (mode 0600 in\na 0700 directory, so only this user reaches it; `~/.lattices/host/control/`\nwhen there is no runtime directory). `POST /visits/end` there closes every\nvisit channel with code 1000 and reason `Ended on this machine`, waits for the\nheld buttons to be released, and answers `{\"ok\":true,\"ended\":N}`. The command\nasks every socket in the directory, so it needs no port or state directory.\n\nTo the visiting device this is a close after `ready` with a normal code: the\nvisit is over, not a failure to retry.\n\n## On the Mac\n\n- Sharing is armed from the menu bar and only taps the mouse while it's armed.\n  Crossing needs a deliberate push past the edge, not just touching it.\n- During a visit the cursor is detached from the mouse\n  (`CGAssociateMouseAndMouseCursorPosition`). A session event tap swallows mouse\n  and key events and forwards them, so nothing on the Mac reacts.\n- ⌃⌥⌘ Home ends the visit from inside that tap, so it works however the channel\n  is doing.\n\n## Machines\n\nOpen **Workspace → Machines** (`hosts` remains a page-name alias). The page\nmerges visit pairings and Lattices hosts by name or\naddress. Selecting a Lattices host retains its still, live view and window list.\nOpening the page does not arm visiting. Connections and status\nreads stop when the page is hidden; arrangement safety timers remain independent.\n\nDrag displays and machines on the scaled arrangement canvas. A machine owns only\nthe stretch of an edge touched by its rectangle; several machines can share a\nside. `Side` is a shortcut that puts a machine beside the outermost display.\nOlder side-only pairings migrate without replacing their trust keys. Newly added\nmachines remain unplaced until arranged.\n\nMoving a local display edits a draft. **Apply** starts a 15-second trial; **Keep**\naccepts it and **Revert** restores the previous origins. Leaving Machines does not\ncancel the rollback timer. Display configuration is never changed by a drag.\n\nEvery display, including the main display, has an **Elsewhere** checkbox. Drop a\nmachine onto an elsewhere display to record what it shows. Pushing toward that\ndisplay visits its assigned machine, using the same deliberate push threshold.\n\n**Add machine** adds a Lattices host, pairs a visit host, or does both. Lattices\nhosts are stored in `~/.lattices/hosts.json`; visit pairing still requires approval\non the other machine. Monitor geometry from `host.describe` is cached for offline\narrangement. Hosts without display information use a generic 1920 × 1080 screen.\n\nThe bundle app can receive visits through its companion bridge. **Let other\nmachines visit this Mac** is off by default. Enabling it enables the existing\nbridge. `/visit` requires signed, replay-checked authentication and the paired\n`input.trackpad` capability. Its encrypted binary frames bind direction, device,\nupgrade nonce and sequence. One visitor is accepted at a time; disabling visits,\ndisconnecting, leaving, or a six-second heartbeat timeout releases held buttons\nand restores the host cursor. No Mac deployment or remote setup is automatic.\n\n```bash\nlats hosts add arts-mini arts-mini\nlats visit place archie 3440 0\nlats visit side archie left\nlats visit elsewhere 2 arts-mini\nlats visit host on        # bundle: explicitly allow receiving visits\nlats visit forget archie\nlats mouse home           # ends the visit and returns the cursor\n```\n\nVisit bridge reachability is checked on page appearance, Refresh and state\nchanges, with no recurring probe timer. Display-to-machine labels are stored by\ndisplay UUID. Arrangement coordinates use the same top-left global point space\nas `CGDisplayBounds`. The exit fraction is measured along the touching span,\nnot the whole local display. No pairing or visiting starts on page open.\n",
      "content": "# Visiting cursor\n\nA visit lends one machine's mouse and keyboard to another without moving the\nother machine's own cursor. Push the pointer past the edge that faces a paired\nhost and it parks there, frozen. From then on your mouse drives a coral\n**visitor** on the host, labelled with your machine's name, the way a screen\nshare shows a second cursor. Your keys type on the host. Nothing on the host\nasks for a new permission: the visit rides the companion bridge's pairing, and\nneeds the `input.trackpad` capability that pairing already grants.\n\nA visit ends when:\n\n- the visitor goes back off the edge it came in by;\n- you press **⌃⌥⌘ Home**, or pick Bring Cursor Home from the menu bar;\n- someone at the host runs `lattices-host mouse-home` (see [Ending a visit from the host](#ending-a-visit-from-the-host));\n- the channel drops, or either side stops hearing from the other for 6 seconds.\n\nYour own cursor never left, so ending a visit can't strand it.\n\n## Channel\n\n`GET /visit` on the gateway (port 9399 by default) upgrades to a WebSocket. The upgrade\nrequest carries the same signed headers as every protected bridge request\n(`X-Lattices-Device-Id`, `-Timestamp`, `-Nonce`, `-Signature`), signed over\nmethod `GET`, path `/visit` and an empty body. The host checks them, and that\nthe device holds `input.trackpad`, before upgrading. Otherwise it answers\n401 or 403 as for any other route.\n\nIn Lattices 0.15.1, the companion bridge listens on a private\nsocket, `~/.lattices/run/companion.sock`, and no longer opens port 5287 or\nadvertises over Bonjour. The visiting machine reaches `/visit` through the\nhost's tailnet gateway (`lats gateway start --tailnet`), on the gateway port\n(9399 by default), with the same signed headers. Configure the gateway address\nexplicitly. The gateway checks tailnet identity; the companion still checks\ndevice pairing, signatures and `input.trackpad`. The gateway relays encrypted\nvisit frames without decrypting them. Companion device trust stays in the\ncompanion's store; approving a gateway RPC client does not grant a visit.\n\nEvery frame after that is a **binary** message: a ChaChaPoly sealed box in its\ncombined form (12-byte nonce, ciphertext, 16-byte tag). There's no base64. The\nkey is the device's encryption key, the one bridge bodies already use. The AAD\nis the UTF-8 of:\n\n```\nvisit\\n<direction>\\n<device id>\\n<upgrade nonce>\\n<seq>\n```\n\n`direction` is `up` from the visiting device and `down` from the host. `seq`\nstarts at 0 in each direction and goes up by one per frame. A frame that\ndoesn't open, or arrives out of sequence, closes the socket with code 1008.\n\nThe plaintext is one JSON object with a type in `t`.\n\n### Up (visiting device → host)\n\n| `t` | Fields | Meaning |\n| --- | --- | --- |\n| `enter` | `name`, `edge`, `at` | Start a visit. `edge` is the side of the host's screens the visitor comes in by (`left`, `right`, `top`, `bottom`). `at` (0–1) is the position along that edge. `name` labels the visitor. |\n| `move` | `dx`, `dy` | Relative motion in the device's points. |\n| `button` | `button`, `down` | `left`, `right` or `middle`, pressed or released. |\n| `scroll` | `dx`, `dy` | Pixels. Positive `dy` scrolls down, positive `dx` scrolls right. |\n| `key` | `key`, `mods` | One keystroke. `key` is an XKB keysym name (`Return`, `a`, `Left`, `F5`); `mods` lists any of `ctrl`, `shift`, `alt`, `super`. The Mac sends ⌘ as `super`. |\n| `text` | `text` | Characters to type as they are. |\n| `ping` | | Sent every 2 seconds. |\n| `leave` | | End the visit. |\n\n### Down (host → visiting device)\n\n| `t` | Fields | Meaning |\n| --- | --- | --- |\n| `ready` | `x`, `y` | The visitor is drawn at this host position. |\n| `exit` | `edge`, `at` | The visitor went back off `edge` at `at` (0–1). The device ends the visit and puts its own cursor at the matching point of its edge. |\n| `pong` | | Answers `ping`. |\n| `error` | `message` | Something failed. The visit goes on unless the socket closes. |\n\n## On the host\n\n- **Area.** The visitor moves over the host's real screens. Virtual outputs, such\n  as Lattices' own `LATS-*`, are left out, the same set Bring Cursor Home picks\n  from. The edge in `enter` is the edge of that area.\n- **Two cursors.** The host's own cursor stays where it is while the visitor\n  moves. For a button press or a scroll, the real pointer goes to the visitor so\n  the input lands there. Keyboard focus must end up where the visitor clicked.\n  When the visit ends, the host's cursor goes back to where it was. A visit\n  that never clicked or scrolled never moved it, so the host leaves it alone.\n- **Release.** If the visit ends while a button is held, the host releases it.\n\n### Ending a visit from the host\n\n`lattices-host mouse-home` ends every visit on this machine, then brings the\nhost's own cursor home as it always has. Each running host listens on a unix\nsocket, `$XDG_RUNTIME_DIR/lattices-host/visit-<bridge port>.sock` (mode 0600 in\na 0700 directory, so only this user reaches it; `~/.lattices/host/control/`\nwhen there is no runtime directory). `POST /visits/end` there closes every\nvisit channel with code 1000 and reason `Ended on this machine`, waits for the\nheld buttons to be released, and answers `{\"ok\":true,\"ended\":N}`. The command\nasks every socket in the directory, so it needs no port or state directory.\n\nTo the visiting device this is a close after `ready` with a normal code: the\nvisit is over, not a failure to retry.\n\n## On the Mac\n\n- Sharing is armed from the menu bar and only taps the mouse while it's armed.\n  Crossing needs a deliberate push past the edge, not just touching it.\n- During a visit the cursor is detached from the mouse\n  (`CGAssociateMouseAndMouseCursorPosition`). A session event tap swallows mouse\n  and key events and forwards them, so nothing on the Mac reacts.\n- ⌃⌥⌘ Home ends the visit from inside that tap, so it works however the channel\n  is doing.\n\n## Machines\n\nOpen **Workspace → Machines** (`hosts` remains a page-name alias). The page\nmerges visit pairings and Lattices hosts by name or\naddress. Selecting a Lattices host retains its still, live view and window list.\nOpening the page does not arm visiting. Connections and status\nreads stop when the page is hidden; arrangement safety timers remain independent.\n\nDrag displays and machines on the scaled arrangement canvas. A machine owns only\nthe stretch of an edge touched by its rectangle; several machines can share a\nside. `Side` is a shortcut that puts a machine beside the outermost display.\nOlder side-only pairings migrate without replacing their trust keys. Newly added\nmachines remain unplaced until arranged.\n\nMoving a local display edits a draft. **Apply** starts a 15-second trial; **Keep**\naccepts it and **Revert** restores the previous origins. Leaving Machines does not\ncancel the rollback timer. Display configuration is never changed by a drag.\n\nEvery display, including the main display, has an **Elsewhere** checkbox. Drop a\nmachine onto an elsewhere display to record what it shows. Pushing toward that\ndisplay visits its assigned machine, using the same deliberate push threshold.\n\n**Add machine** adds a Lattices host, pairs a visit host, or does both. Lattices\nhosts are stored in `~/.lattices/hosts.json`; visit pairing still requires approval\non the other machine. Monitor geometry from `host.describe` is cached for offline\narrangement. Hosts without display information use a generic 1920 × 1080 screen.\n\nThe bundle app can receive visits through its companion bridge. **Let other\nmachines visit this Mac** is off by default. Enabling it enables the existing\nbridge. `/visit` requires signed, replay-checked authentication and the paired\n`input.trackpad` capability. Its encrypted binary frames bind direction, device,\nupgrade nonce and sequence. One visitor is accepted at a time; disabling visits,\ndisconnecting, leaving, or a six-second heartbeat timeout releases held buttons\nand restores the host cursor. No Mac deployment or remote setup is automatic.\n\n```bash\nlats hosts add arts-mini arts-mini\nlats visit place archie 3440 0\nlats visit side archie left\nlats visit elsewhere 2 arts-mini\nlats visit host on        # bundle: explicitly allow receiving visits\nlats visit forget archie\nlats mouse home           # ends the visit and returns the cursor\n```\n\nVisit bridge reachability is checked on page appearance, Refresh and state\nchanges, with no recurring probe timer. Display-to-machine labels are stored by\ndisplay UUID. Arrangement coordinates use the same top-left global point space\nas `CGDisplayBounds`. The exit fraction is measured along the touching span,\nnot the whole local display. No pairing or visiting starts on page open."
    },
    {
      "id": "lattices:voice",
      "slug": "voice",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Voice",
      "description": "Voice commands for window management and the lats voice reference, including speech output forwarded to the Voice app",
      "order": 7,
      "sourcePath": "docs/voice.md",
      "url": "/lattices/docs/voice",
      "rawUrl": "/lattices/docs/markdown/voice.md",
      "markdownUrl": "/lattices/docs/voice.md",
      "headings": [
        {
          "depth": 2,
          "text": "Embedded voice runtime",
          "anchor": "embedded-voice-runtime"
        },
        {
          "depth": 3,
          "text": "Private connection",
          "anchor": "private-connection"
        },
        {
          "depth": 2,
          "text": "Quick start",
          "anchor": "quick-start"
        },
        {
          "depth": 2,
          "text": "Voice API",
          "anchor": "voice-api"
        },
        {
          "depth": 2,
          "text": "Keyboard shortcuts",
          "anchor": "keyboard-shortcuts"
        },
        {
          "depth": 2,
          "text": "Built-in commands",
          "anchor": "built-in-commands"
        },
        {
          "depth": 3,
          "text": "Search",
          "anchor": "search"
        },
        {
          "depth": 3,
          "text": "Tile",
          "anchor": "tile"
        },
        {
          "depth": 3,
          "text": "Focus",
          "anchor": "focus"
        },
        {
          "depth": 3,
          "text": "Open / Launch",
          "anchor": "open-launch"
        },
        {
          "depth": 3,
          "text": "Kill",
          "anchor": "kill"
        },
        {
          "depth": 3,
          "text": "Scan",
          "anchor": "scan"
        },
        {
          "depth": 3,
          "text": "Other",
          "anchor": "other"
        },
        {
          "depth": 2,
          "text": "AI advisor",
          "anchor": "ai-advisor"
        },
        {
          "depth": 3,
          "text": "How it works",
          "anchor": "how-it-works"
        },
        {
          "depth": 2,
          "text": "Hands-off inference",
          "anchor": "hands-off-inference"
        },
        {
          "depth": 2,
          "text": "Configuration",
          "anchor": "configuration"
        },
        {
          "depth": 2,
          "text": "Layout",
          "anchor": "layout"
        },
        {
          "depth": 2,
          "text": "Search architecture",
          "anchor": "search-architecture"
        },
        {
          "depth": 2,
          "text": "Processing resilience",
          "anchor": "processing-resilience"
        },
        {
          "depth": 2,
          "text": "Advisor learning",
          "anchor": "advisor-learning"
        },
        {
          "depth": 2,
          "text": "Requirements",
          "anchor": "requirements"
        }
      ],
      "tokensEstimate": 2130,
      "frontmatter": {
        "title": "Voice",
        "description": "Voice commands for window management and the lats voice reference, including speech output forwarded to the Voice app",
        "order": 7
      },
      "markdown": "---\ntitle: Voice\ndescription: Voice commands for window management and the lats voice reference, including speech output forwarded to the Voice app\norder: 7\n---\n\nVoice commands let you control Lattices by speaking. Press **Hyper+D**\nto open the voice command window, hold **Option** to speak, release to\nstop. Lattices transcribes your speech via its **embedded Hudson Voice /\nVox runtime**, matches it to an intent, and executes it.\n\nThe `voice` domain has two directions with two owners:\n\n| Direction | Verbs | Owner | Transport |\n|-----------|-------|-------|------|\n| Input: voice commands | `voice.listen`, `voice.stopListening`, `voice.simulate`, `voice.status` | Lattices menu bar app | `lattices.sock`, `voice.sock` |\n| Output: spoken text | `voice.say`, `voice.stop`, `voice.pause`, `voice.resume`, `voice.skip`, `voice.seek`, `voice.list`, `voice.select`, `voice.lease`, `voice.release` | `Voice.app`, reached through the Lattices daemon | `lattices.sock`, `speech.sock` |\n\nThis page is the reference for both directions of `lats voice`. Speech output\nitself (providers, queue and playback rules, the direct `speech.*` API) is\ndocumented in the [Voice docs](/voice/docs/overview).\n\n## Embedded voice runtime\n\nLattices **hosts** the voice engine inside the menu bar process. You do not\nneed a separate `voxd` for normal use. On app boot, Lattices starts an\nin-process live-session server and tears it down on quit.\n\nLattices 0.15.1 and Voice 0.3.0 use private local connections. Install the\nLattices app and CLI at the same version.\n\n### Private connection\n\n| Service | Endpoint |\n|---------|-----------------------|\n| Agent API / daemon | `~/.lattices/run/lattices.sock` |\n| Voice runtime | `~/.lattices/run/voice.sock` |\n| `Voice.app` | `~/.lattices/run/speech.sock` |\n\nThe voice runtime uses Vox's private transport: a Unix socket that accepts only\nsame-user peers and requires the `x-lattices-token` header on the WebSocket\nupgrade. Hudson Voice connects with the token for you. The capability file at\n`~/Library/Application Support/Lattices/Voice/hudson-voice-runtime.json`\npoints at the socket. These sockets are local only;\nremote voice input is not exposed through the tailnet gateway.\n\n## Quick start\n\n1. Grant microphone access when prompted (Settings → Voice if you skipped it)\n2. Optionally connect a voice provider in **Settings > Voice** for provider-backed interpretation and speech services\n3. Press **Hyper+D** to open the voice command window\n4. Hold **Option** and speak a command\n5. Release **Option**. Lattices transcribes and executes\n\n## Voice API\n\nThe `voice` domain covers listening and speaking. The daemon serves\nlistening. `Voice.app` speaks: the daemon renames each output verb\nto its `speech.*` method and forwards it to `Voice.app` through `speech.sock`. Install\nor open `Voice.app` from the Lattices menu under **Apps**.\n\n| Verb | CLI | Does |\n|------|-----|------|\n| `voice.say`     | `lats voice say \"text\"` | Speak text. Returns the job id; the job is queued, not finished. |\n| `voice.stop`     | `lats voice stop` | Stop speaking and clear the queue. |\n| `voice.pause` / `voice.resume`     | `lats voice pause` / `resume` | Pause or resume the current job. |\n| `voice.skip`     | `lats voice skip` | Skip to the next queued job. |\n| `voice.seek`     | `lats voice seek <seconds>` | Seek the current job. |\n| `voice.list`     | `lats voice list` | List voices. |\n| `voice.select`     | `lats voice select <voice>` | Choose the default voice for a provider. |\n| `voice.lease` / `voice.release` | none | Hold the speaker so other agents do not talk over you. A lease also ends when the connection closes. |\n| `voice.listen`     | `lats voice listen` | Start voice capture. |\n| `voice.stopListening`     | `lats voice stopListening` | Stop voice capture and run the transcript. |\n| `voice.simulate`     | `lats voice simulate \"text\"` | Parse and run a command as if spoken. |\n| `voice.status`     | `lats voice status` | Listening state, plus the Voice helper under `helper`. |\n\n`voice.stop` used to stop listening. It now stops speaking. For one\nrelease, a `voice.stop` made while Lattices is listening and nothing is\nspeaking fails with `voice_stop_changed` and points to\n`voice.stopListening`.\n\nIf the Voice helper is not installed, the speaking verbs fail with\n`helper_not_installed` and the fix: **Lattices › Apps › Install Voice**.\nIf it is installed but not running, they fail with `helper_unreachable`;\nopen Voice from **Lattices › Apps**. There is no fallback. Speaking verbs\nneed the helper's local capability\n(`~/Library/Application Support/Speech/RPC/capability`), which exists only\nwhile Voice runs; the `lats` CLI sends it for you, and a client that\ndoes not gets `helper_unauthorized`. The daemon still accepts the older `speech.*` names, and\nsends each `speech.changed` event again as `voice.changed`.\n\n## Keyboard shortcuts\n\n| Key | Action |\n|-----|--------|\n| **Hyper+D** | Open/close voice command window |\n| **⌥ (hold)** | Push-to-talk: hold to record, release to stop |\n| **Tab** | Arm/disarm the mic |\n| **Escape** | Cancel recording or dismiss window |\n\n## Built-in commands\n\n### Search\n\nFind windows by app name, title, content, or category.\n\n```\n\"Find all vox windows\"\n\"Find terminals\"           → expands to iTerm, Terminal, Warp, etc.\n\"Show me all browsers\"     → expands to Safari, Chrome, Firefox, Arc, etc.\n\"Where is my editor?\"      → expands to VS Code, Cursor, Xcode, etc.\n```\n\nCategory synonyms are built in. Saying \"terminals\", \"browsers\", \"editors\",\n\"chat\", \"music\", \"mail\", or \"notes\" automatically expands to search for\nthe actual app names.\n\n### Tile\n\nMove windows to screen positions.\n\n```\n\"Tile this left\"\n\"Snap to the right half\"\n\"Maximize the window\"\n\"Put this in the top right corner\"\n```\n\nVoice tiling should resolve into the same canonical daemon mutation used\nby other agent surfaces: `windows.place`.\n\n### Focus\n\nBring a window or app to the front.\n\n```\n\"Focus Safari\"\n\"Switch to Slack\"\n\"Go to the lats window\"\n```\n\n### Open / Launch\n\nOpen applications or project workspaces.\n\n```\n\"Open Spotify\"\n\"Launch the vox project\"\n```\n\n### Kill\n\nClose windows or quit applications.\n\n```\n\"Kill this window\"\n\"Close Safari\"\n\"Quit Spotify\"\n```\n\n### Scan\n\nTrigger an OCR scan of visible windows.\n\n```\n\"Scan the screen\"\n\"Read what's on screen\"\n```\n\n### Other\n\n```\n\"List all windows\"\n\"Show my sessions\"\n\"Switch to layer 2\"\n\"Help\"\n```\n\n## AI advisor\n\nEvery voice command can ask the selected voice provider for\ncommentary and follow-up suggestions in the **AI corner** (bottom-right\nof the voice command window).\n\nWhen local matching handles the command well, the AI corner shows\n\"no AI needed\" with an optional \"ask AI\" button. When the advisor\nhas something useful, it shows a one-line comment and an actionable\nsuggestion button.\n\n### How it works\n\n1. You speak a command\n2. Local intent matching runs immediately (fast, free)\n3. The selected voice provider runs in parallel when connected\n4. If the advisor suggests something, a button appears in the AI corner\n5. Click the suggestion to execute it\n6. If you engage with a suggestion that the local matcher missed,\n   it's recorded in `~/.lattices/advisor-learning.jsonl` for future\n   improvement\n\n## Hands-off inference\n\nHands-off voice uses the shared inference wrapper in `bin/infer.ts`.\nBy default it chooses the lowest-latency configured provider and, when\nGroq credentials are present, uses `groq/llama-3.1-8b-instant`.\n\nProvider keys are read from the process env (`GROQ_API_KEY`,\n`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`,\n`XAI_API_KEY`, `MINIMAX_API_KEY`), then from the macOS Keychain under the\nservice `lattices.inference`, one account per provider. Lattices does not\nread `.env` files. Store a key without putting it on a command line:\n\n```bash\nsecurity add-generic-password -U -s lattices.inference -a groq -w\n```\n\nEvery variable Lattices reads is declared in `.env.schema`.\n\nOverride the voice engine if needed:\n\n```bash\nLATTICES_VOICE_PROVIDER=groq\nLATTICES_VOICE_MODEL=llama-3.1-8b-instant\n```\n\n## Configuration\n\nOpen **Settings > AI** to inspect Scout chat status, and **Settings > Voice**\nto configure the optional voice provider:\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| Workspace chat | Scout | Uses the existing local Scout broker and Scout-owned authorization; Lattices does not store a separate chat key. |\n| Voice provider | OpenAI | Optional direct HudsonAI provider used only by provider-backed voice interpretation and speech services. OpenAI, Anthropic, and OpenRouter are supported. |\n| Voice provider credentials | Not authenticated | Save an optional voice-only API key locally in the macOS Keychain. |\n\n## Layout\n\nThe voice command window has four sections:\n\n| Section | Position | Content |\n|---------|----------|---------|\n| **History** | Left column | Past commands with expandable details |\n| **Voice Command** | Center column | Current transcript, matched intent, results |\n| **Log** | Top-right | Rolling diagnostic log (last 12 entries) |\n| **AI Corner** | Bottom-right | Advisor commentary, suggestions, provider readiness |\n\n## Search architecture\n\nVoice search uses the same backend as `lats search`:\n\n1. **Quick search**: window titles, app names, session tags (instant)\n2. **Complete search**: adds terminal cwd/processes + OCR content\n3. **Synonym expansion**: category terms like \"terminals\" expand to\n   actual app names before searching\n4. **Query cleanup**: strips natural language qualifiers (\"and sort by...\",\n   \"please\", \"for me\") before searching\n\n## Processing resilience\n\n- **15-second timeout**: if processing doesn't complete, returns to idle\n- **Cancellation on dismiss**: closing the window cancels in-flight work\n- **Double-execution prevention**: streaming and stop callbacks can't\n  both fire the intent\n\n## Advisor learning\n\nWhen the local matcher fails but the AI advisor suggests something that\nyou engage with, the interaction is recorded:\n\n```\n~/.lattices/advisor-learning.jsonl\n```\n\nEach line is a JSON object:\n\n```json\n{\n  \"timestamp\": \"2026-03-15T18:30:00.000Z\",\n  \"transcript\": \"find all terminals\",\n  \"localIntent\": \"search\",\n  \"localSlots\": {\"query\": \"terminals\"},\n  \"localResultCount\": 0,\n  \"advisorIntent\": \"search\",\n  \"advisorSlots\": {\"query\": \"iterm\"},\n  \"advisorLabel\": \"Search iTerm\"\n}\n```\n\nThis dataset captures where the local system falls short and what the\nright answer was. Future work can mine it for automatic synonym\nmappings and phrase pattern improvements.\n\n## Requirements\n\n- **Embedded voice runtime**: the in-process Vox runtime on `voice.sock` provides\n  speech-to-text. It ships inside builds that include Hudson Voice; no\n  separate `voxd` install is needed\n- **Microphone** permission: for voice capture\n- **Voice provider**: enables provider-backed AI suggestions from\n  **Settings > Voice** (optional, voice commands still work without it)\n- **Accessibility** permission: for window tiling and focus\n- **Screen Recording** permission: for window discovery\n- **`Voice.app`**: only for the output verbs such as `lats voice say`.\n  See [Voice quickstart](/voice/docs/quickstart)\n",
      "content": "Voice commands let you control Lattices by speaking. Press **Hyper+D**\nto open the voice command window, hold **Option** to speak, release to\nstop. Lattices transcribes your speech via its **embedded Hudson Voice /\nVox runtime**, matches it to an intent, and executes it.\n\nThe `voice` domain has two directions with two owners:\n\n| Direction | Verbs | Owner | Transport |\n|-----------|-------|-------|------|\n| Input: voice commands | `voice.listen`, `voice.stopListening`, `voice.simulate`, `voice.status` | Lattices menu bar app | `lattices.sock`, `voice.sock` |\n| Output: spoken text | `voice.say`, `voice.stop`, `voice.pause`, `voice.resume`, `voice.skip`, `voice.seek`, `voice.list`, `voice.select`, `voice.lease`, `voice.release` | `Voice.app`, reached through the Lattices daemon | `lattices.sock`, `speech.sock` |\n\nThis page is the reference for both directions of `lats voice`. Speech output\nitself (providers, queue and playback rules, the direct `speech.*` API) is\ndocumented in the [Voice docs](/voice/docs/overview).\n\n## Embedded voice runtime\n\nLattices **hosts** the voice engine inside the menu bar process. You do not\nneed a separate `voxd` for normal use. On app boot, Lattices starts an\nin-process live-session server and tears it down on quit.\n\nLattices 0.15.1 and Voice 0.3.0 use private local connections. Install the\nLattices app and CLI at the same version.\n\n### Private connection\n\n| Service | Endpoint |\n|---------|-----------------------|\n| Agent API / daemon | `~/.lattices/run/lattices.sock` |\n| Voice runtime | `~/.lattices/run/voice.sock` |\n| `Voice.app` | `~/.lattices/run/speech.sock` |\n\nThe voice runtime uses Vox's private transport: a Unix socket that accepts only\nsame-user peers and requires the `x-lattices-token` header on the WebSocket\nupgrade. Hudson Voice connects with the token for you. The capability file at\n`~/Library/Application Support/Lattices/Voice/hudson-voice-runtime.json`\npoints at the socket. These sockets are local only;\nremote voice input is not exposed through the tailnet gateway.\n\n## Quick start\n\n1. Grant microphone access when prompted (Settings → Voice if you skipped it)\n2. Optionally connect a voice provider in **Settings > Voice** for provider-backed interpretation and speech services\n3. Press **Hyper+D** to open the voice command window\n4. Hold **Option** and speak a command\n5. Release **Option**. Lattices transcribes and executes\n\n## Voice API\n\nThe `voice` domain covers listening and speaking. The daemon serves\nlistening. `Voice.app` speaks: the daemon renames each output verb\nto its `speech.*` method and forwards it to `Voice.app` through `speech.sock`. Install\nor open `Voice.app` from the Lattices menu under **Apps**.\n\n| Verb | CLI | Does |\n|------|-----|------|\n| `voice.say`     | `lats voice say \"text\"` | Speak text. Returns the job id; the job is queued, not finished. |\n| `voice.stop`     | `lats voice stop` | Stop speaking and clear the queue. |\n| `voice.pause` / `voice.resume`     | `lats voice pause` / `resume` | Pause or resume the current job. |\n| `voice.skip`     | `lats voice skip` | Skip to the next queued job. |\n| `voice.seek`     | `lats voice seek <seconds>` | Seek the current job. |\n| `voice.list`     | `lats voice list` | List voices. |\n| `voice.select`     | `lats voice select <voice>` | Choose the default voice for a provider. |\n| `voice.lease` / `voice.release` | none | Hold the speaker so other agents do not talk over you. A lease also ends when the connection closes. |\n| `voice.listen`     | `lats voice listen` | Start voice capture. |\n| `voice.stopListening`     | `lats voice stopListening` | Stop voice capture and run the transcript. |\n| `voice.simulate`     | `lats voice simulate \"text\"` | Parse and run a command as if spoken. |\n| `voice.status`     | `lats voice status` | Listening state, plus the Voice helper under `helper`. |\n\n`voice.stop` used to stop listening. It now stops speaking. For one\nrelease, a `voice.stop` made while Lattices is listening and nothing is\nspeaking fails with `voice_stop_changed` and points to\n`voice.stopListening`.\n\nIf the Voice helper is not installed, the speaking verbs fail with\n`helper_not_installed` and the fix: **Lattices › Apps › Install Voice**.\nIf it is installed but not running, they fail with `helper_unreachable`;\nopen Voice from **Lattices › Apps**. There is no fallback. Speaking verbs\nneed the helper's local capability\n(`~/Library/Application Support/Speech/RPC/capability`), which exists only\nwhile Voice runs; the `lats` CLI sends it for you, and a client that\ndoes not gets `helper_unauthorized`. The daemon still accepts the older `speech.*` names, and\nsends each `speech.changed` event again as `voice.changed`.\n\n## Keyboard shortcuts\n\n| Key | Action |\n|-----|--------|\n| **Hyper+D** | Open/close voice command window |\n| **⌥ (hold)** | Push-to-talk: hold to record, release to stop |\n| **Tab** | Arm/disarm the mic |\n| **Escape** | Cancel recording or dismiss window |\n\n## Built-in commands\n\n### Search\n\nFind windows by app name, title, content, or category.\n\n```\n\"Find all vox windows\"\n\"Find terminals\"           → expands to iTerm, Terminal, Warp, etc.\n\"Show me all browsers\"     → expands to Safari, Chrome, Firefox, Arc, etc.\n\"Where is my editor?\"      → expands to VS Code, Cursor, Xcode, etc.\n```\n\nCategory synonyms are built in. Saying \"terminals\", \"browsers\", \"editors\",\n\"chat\", \"music\", \"mail\", or \"notes\" automatically expands to search for\nthe actual app names.\n\n### Tile\n\nMove windows to screen positions.\n\n```\n\"Tile this left\"\n\"Snap to the right half\"\n\"Maximize the window\"\n\"Put this in the top right corner\"\n```\n\nVoice tiling should resolve into the same canonical daemon mutation used\nby other agent surfaces: `windows.place`.\n\n### Focus\n\nBring a window or app to the front.\n\n```\n\"Focus Safari\"\n\"Switch to Slack\"\n\"Go to the lats window\"\n```\n\n### Open / Launch\n\nOpen applications or project workspaces.\n\n```\n\"Open Spotify\"\n\"Launch the vox project\"\n```\n\n### Kill\n\nClose windows or quit applications.\n\n```\n\"Kill this window\"\n\"Close Safari\"\n\"Quit Spotify\"\n```\n\n### Scan\n\nTrigger an OCR scan of visible windows.\n\n```\n\"Scan the screen\"\n\"Read what's on screen\"\n```\n\n### Other\n\n```\n\"List all windows\"\n\"Show my sessions\"\n\"Switch to layer 2\"\n\"Help\"\n```\n\n## AI advisor\n\nEvery voice command can ask the selected voice provider for\ncommentary and follow-up suggestions in the **AI corner** (bottom-right\nof the voice command window).\n\nWhen local matching handles the command well, the AI corner shows\n\"no AI needed\" with an optional \"ask AI\" button. When the advisor\nhas something useful, it shows a one-line comment and an actionable\nsuggestion button.\n\n### How it works\n\n1. You speak a command\n2. Local intent matching runs immediately (fast, free)\n3. The selected voice provider runs in parallel when connected\n4. If the advisor suggests something, a button appears in the AI corner\n5. Click the suggestion to execute it\n6. If you engage with a suggestion that the local matcher missed,\n   it's recorded in `~/.lattices/advisor-learning.jsonl` for future\n   improvement\n\n## Hands-off inference\n\nHands-off voice uses the shared inference wrapper in `bin/infer.ts`.\nBy default it chooses the lowest-latency configured provider and, when\nGroq credentials are present, uses `groq/llama-3.1-8b-instant`.\n\nProvider keys are read from the process env (`GROQ_API_KEY`,\n`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`,\n`XAI_API_KEY`, `MINIMAX_API_KEY`), then from the macOS Keychain under the\nservice `lattices.inference`, one account per provider. Lattices does not\nread `.env` files. Store a key without putting it on a command line:\n\n```bash\nsecurity add-generic-password -U -s lattices.inference -a groq -w\n```\n\nEvery variable Lattices reads is declared in `.env.schema`.\n\nOverride the voice engine if needed:\n\n```bash\nLATTICES_VOICE_PROVIDER=groq\nLATTICES_VOICE_MODEL=llama-3.1-8b-instant\n```\n\n## Configuration\n\nOpen **Settings > AI** to inspect Scout chat status, and **Settings > Voice**\nto configure the optional voice provider:\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| Workspace chat | Scout | Uses the existing local Scout broker and Scout-owned authorization; Lattices does not store a separate chat key. |\n| Voice provider | OpenAI | Optional direct HudsonAI provider used only by provider-backed voice interpretation and speech services. OpenAI, Anthropic, and OpenRouter are supported. |\n| Voice provider credentials | Not authenticated | Save an optional voice-only API key locally in the macOS Keychain. |\n\n## Layout\n\nThe voice command window has four sections:\n\n| Section | Position | Content |\n|---------|----------|---------|\n| **History** | Left column | Past commands with expandable details |\n| **Voice Command** | Center column | Current transcript, matched intent, results |\n| **Log** | Top-right | Rolling diagnostic log (last 12 entries) |\n| **AI Corner** | Bottom-right | Advisor commentary, suggestions, provider readiness |\n\n## Search architecture\n\nVoice search uses the same backend as `lats search`:\n\n1. **Quick search**: window titles, app names, session tags (instant)\n2. **Complete search**: adds terminal cwd/processes + OCR content\n3. **Synonym expansion**: category terms like \"terminals\" expand to\n   actual app names before searching\n4. **Query cleanup**: strips natural language qualifiers (\"and sort by...\",\n   \"please\", \"for me\") before searching\n\n## Processing resilience\n\n- **15-second timeout**: if processing doesn't complete, returns to idle\n- **Cancellation on dismiss**: closing the window cancels in-flight work\n- **Double-execution prevention**: streaming and stop callbacks can't\n  both fire the intent\n\n## Advisor learning\n\nWhen the local matcher fails but the AI advisor suggests something that\nyou engage with, the interaction is recorded:\n\n```\n~/.lattices/advisor-learning.jsonl\n```\n\nEach line is a JSON object:\n\n```json\n{\n  \"timestamp\": \"2026-03-15T18:30:00.000Z\",\n  \"transcript\": \"find all terminals\",\n  \"localIntent\": \"search\",\n  \"localSlots\": {\"query\": \"terminals\"},\n  \"localResultCount\": 0,\n  \"advisorIntent\": \"search\",\n  \"advisorSlots\": {\"query\": \"iterm\"},\n  \"advisorLabel\": \"Search iTerm\"\n}\n```\n\nThis dataset captures where the local system falls short and what the\nright answer was. Future work can mine it for automatic synonym\nmappings and phrase pattern improvements.\n\n## Requirements\n\n- **Embedded voice runtime**: the in-process Vox runtime on `voice.sock` provides\n  speech-to-text. It ships inside builds that include Hudson Voice; no\n  separate `voxd` install is needed\n- **Microphone** permission: for voice capture\n- **Voice provider**: enables provider-backed AI suggestions from\n  **Settings > Voice** (optional, voice commands still work without it)\n- **Accessibility** permission: for window tiling and focus\n- **Screen Recording** permission: for window discovery\n- **`Voice.app`**: only for the output verbs such as `lats voice say`.\n  See [Voice quickstart](/voice/docs/quickstart)"
    },
    {
      "id": "lattices:agents",
      "slug": "agents",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Agent Guide",
      "description": "Canonical contracts for agents using docs URLs, CLI, voice, and the daemon API",
      "order": 6,
      "sourcePath": "docs/agents.md",
      "url": "/lattices/docs/agents",
      "rawUrl": "/lattices/docs/markdown/agents.md",
      "markdownUrl": "/lattices/docs/agents/index.md",
      "headings": [
        {
          "depth": 2,
          "text": "First check",
          "anchor": "first-check"
        },
        {
          "depth": 2,
          "text": "Install the skills",
          "anchor": "install-the-skills"
        },
        {
          "depth": 2,
          "text": "Action private layers",
          "anchor": "action-private-layers"
        },
        {
          "depth": 2,
          "text": "Canonical mutations",
          "anchor": "canonical-mutations"
        },
        {
          "depth": 2,
          "text": "Discoverability",
          "anchor": "discoverability"
        },
        {
          "depth": 2,
          "text": "Agent documentation artifacts",
          "anchor": "agent-documentation-artifacts"
        },
        {
          "depth": 3,
          "text": "Discovery and semantic metadata",
          "anchor": "discovery-and-semantic-metadata"
        },
        {
          "depth": 2,
          "text": "Placement contract",
          "anchor": "placement-contract"
        },
        {
          "depth": 2,
          "text": "Choosing a surface",
          "anchor": "choosing-a-surface"
        },
        {
          "depth": 2,
          "text": "Examples",
          "anchor": "examples"
        },
        {
          "depth": 2,
          "text": "Receipts and traceability",
          "anchor": "receipts-and-traceability"
        },
        {
          "depth": 2,
          "text": "Voice and hands-off",
          "anchor": "voice-and-hands-off"
        },
        {
          "depth": 2,
          "text": "Assistant intelligence boundary",
          "anchor": "assistant-intelligence-boundary"
        }
      ],
      "tokensEstimate": 2561,
      "frontmatter": {
        "title": "Agent Guide",
        "description": "Canonical contracts for agents using docs URLs, CLI, voice, and the daemon API",
        "order": 6
      },
      "markdown": "---\ntitle: Agent Guide\ndescription: Canonical contracts for agents using docs URLs, CLI, voice, and the daemon API\norder: 6\n---\n\nLattices exposes the same execution model through these interfaces:\n\n- **Docs URLs** for discovery and citation\n- **CLI** for shell-based agents\n- **Daemon API** for typed programmatic control\n- **Voice / hands-off / HUD** as clients of the same execution layer\n\nThe rule is simple: agents should learn the **canonical action model**\nfirst, then choose the transport that fits the environment.\n\n## First check\n\nRun these read-only commands before any mutation. They do not move windows.\n\n```bash\nlats call api.schema        # every daemon method and its parameters\nlats call desktop.snapshot  # what is on screen now, with window ids\n```\n\nExpect a method list, then a snapshot of the current space. If either call\nfails, the usual cause is that the menu bar app, which hosts the daemon, is not\nrunning. Ask the user to start it with `lats app`. Then resolve a target with `windows.resolve` or `dryRun` before you\nplace a window. See [Discoverability](#discoverability).\n\n## Install the skills\n\n`lattices-dev/lattices` is the [skills.sh](https://skills.sh) catalog for the product\nfamily. Skills live under [`skills/`](https://github.com/lattices-dev/lattices/tree/main/skills).\n\n| Skill | Product |\n| --- | --- |\n| `lattices` | Workspace control, tiling, sessions, screen text |\n| `action` | Native observe / resolve / act / record |\n| `voice` | The `voice` domain: spoken commands, voice intents, and speech output through the Voice app |\n| `blink` | Spatial notes through the `blink` CLI |\n\n```bash\nnpx skills add lattices-dev/lattices --list\nnpx skills add lattices-dev/lattices --all --global --yes\nnpx skills add lattices-dev/lattices --skill lattices --global --yes\nnpx skills add lattices-dev/lattices --skill action --global --yes\nnpx skills add lattices-dev/lattices --skill voice --global --yes\nnpx skills add lattices-dev/lattices --skill blink --global --yes\n```\n\nThe `browser_*` tools come from `lats mcp`, not from a plugin. The\n`action-browser@lattices` plugin carries only the skill text. See\n[MCP](/lattices/docs/mcp).\n\nEach product owns its own work. [Concepts](/lattices/docs/concepts#where-commands-go)\nlists every `lats` command, the process that receives it, and the product\nthat does the work. Product docs: [Action](/action/docs/overview),\n[Canvas](/canvas/docs/overview), [Voice](/voice/docs/overview).\n\nTo install the Action product itself, run `lattices action install`. It\ndownloads the latest signed `Action.app` release to `/Applications`.\n`lattices action status` reports install, runtime, and agent state;\n`lattices action call <method>` talks to the Action agent without a monorepo\ncheckout. Action 0.4.0 and Lattices 0.15.1 use authenticated private IPC;\nearlier releases use the local agent port. Let the matching CLI discover the endpoint.\nSee [Processes and ports](/lattices/docs/concepts#processes-and-ports).\n\n## Action private layers\n\nAction private layers create virtual displays. Lattices workspace layers group\nwindows and use `layer.activate`; they do not create Action's private surface.\nFor private browser work, use Action MCP `layer_open { browser: true }`,\n`layer_snapshot`, `layer_click/type/js`, and `layer_close`.\n\nFirst run `lats action capabilities` and query `capabilities` plus `tools/list`\non the active Action MCP connection. Compare source/native fingerprints, check\n`safety.privateLayer`, and require host acceptance for desktop preservation.\nStop on quarantine; do not rebuild an installation target or fall back to a\nregular browser. A missing block is not proof of isolation. Reconnect MCP after\nupdates and repeat discovery. Matching a published version does not verify\nMCP or native feature parity.\n\nThe CLI offers layer lifecycle, snapshot, PiP, and recording. Browser flags,\ninput, navigation, and JavaScript require MCP. Raw Action WebSocket and the\nLattices daemon do not provide that orchestration. Follow the\n[Action skill](https://github.com/lattices-dev/lattices/blob/main/skills/action/SKILL.md)\nand [layer reference](/action/docs/agent-layer)\nfor leases, operator takeover, cleanup, and acceptance requirements.\n\n## Canonical mutations\n\nThese are the preferred action identifiers:\n\n| Action | Purpose | Preferred surface |\n|--------|---------|-------------------|\n| `windows.place` | Place a window or session using a typed placement spec | `actions.execute` |\n| `layers.activate` | Bring up a workspace layer with explicit activation mode | Daemon API |\n| `spaces.optimize` | Rebalance a set of windows using an explicit scope and strategy | Daemon API |\n| `tabs.stack` | Turn explicit or Hyperspace-selected windows into ephemeral cross-app tabs | Daemon API |\n| `tabs.layout` | Expand a live tab stack to grid or collapse it back to tabs | Daemon API |\n\nCompatibility wrappers still exist:\n\n| Legacy method | Canonical equivalent |\n|---------------|----------------------|\n| `windows.place` | `actions.execute` with `type=windows.place` |\n| `window.tile` | `windows.place` |\n| `layers.switch` | `layers.activate` |\n| `layout.distribute` | `spaces.optimize` with `scope=visible`, `strategy=balanced` |\n\n## Discoverability\n\nAgents should use these paths in order:\n\n1. **API schema**: `lats call api.schema`\n2. **Read-only workspace map and UI hygiene**: [/lattices/docs/workspace-map](/lattices/docs/workspace-map)\n3. **Daemon reference**: [/lattices/docs/api](/lattices/docs/api)\n4. **Agent guide**: [/lattices/docs/agents](/lattices/docs/agents)\n5. **Voice model**: [/lattices/docs/voice](/lattices/docs/voice)\n6. **Concepts / config**: [/lattices/docs/concepts](/lattices/docs/concepts), [/lattices/docs/config](/lattices/docs/config)\n\nUseful CLI discovery commands:\n\n```bash\nlats help\nlats call api.schema\nlats call desktop.snapshot\nlats map\nlats call terminals.capture '{\"session\":\"<live-session>\",\"lines\":40}'\nlats voice intents\n```\n\n`desktop.snapshot` is the structured “what is in front of the user?” read.\n`lats map` draws that same current-space view as ASCII. `terminals.capture`\nis exact tmux pane text, not OCR.\n\nFor window actions, resolve before mutating when target identity matters:\n\n```bash\nlats call windows.resolve '{\"target\":{\"kind\":\"session\",\"session\":\"frontend-a1b2c3\"},\"placement\":\"left\"}'\nlats call actions.execute '{\"type\":\"window.place\",\"target\":{\"kind\":\"session\",\"session\":\"frontend-a1b2c3\"},\"args\":{\"placement\":\"left\"},\"dryRun\":true}'\n```\n\n`windows.resolve` and `dryRun` return the resolved `wid`, app/title,\ntarget resolution path, display, and planned frame without moving the\nwindow.\n\nVerified `windows.place` receipts include `undoable: true` when Lattices\ncaptured a concrete window id and previous frame. Agents can restore the\nlatest undoable placement, or an entire request batch, through:\n\n```bash\nlats call history.undo '{}'\nlats call history.undo '{\"requestId\":\"req_...\"}'\nlats call history.undo '{\"receiptId\":\"exec_...\",\"dryRun\":true}'\n```\n\nUndo checks that the window is still where the original receipt left it\nbefore restoring the previous frame. Pass `force:true` only when the\ncaller intentionally wants to override a later manual move.\n\n## Agent documentation artifacts\n\nThe site build emits agent-first files in addition to rendered pages.\nUse these when an agent needs raw markdown, prompt text, or a compact\ncontext bundle without scraping HTML. Every docs page also has a\n**Use with an agent** control that copies the page or its product's docs.\n\nPrefer the product-scoped files. They hold only that product's current\nguides. `<product>` is `lattices`, `action`, `blink`, or `speech`.\n\n| Need | URL |\n|------|-----|\n| One product's index and first check | `/<product>/docs/llms.txt` |\n| One product's current docs, full text | `/<product>/docs/llms-full.txt` |\n| One product's docs as JSON | `/<product>/docs/agent-docs.json` |\n| One page as readable Markdown | `/<product>/docs/<slug>.md` or `/<product>/docs/<slug>/index.md` |\n| Exact Markdown source | `/<product>/docs/markdown/<slug>.md` |\n| Public product instructions | `/<product>/docs/AGENTS.md` |\n\nHTML pages advertise their Markdown representation with `rel=\"alternate\"`\nand the relevant product index with `rel=\"describedby\"`. The Markdown page\nlinks resolve to canonical web pages; source mirrors preserve the original\nbytes. Product indexes follow the [llms.txt proposal](https://llmstxt.org/).\n`llms-full.txt` is a complete-context convenience file, not a separate standard.\n\nPages named `agents` use `/agents/index.md` so their Markdown does not collide\nwith public `AGENTS.md` instructions on case-insensitive filesystems. Follow\nthe page's advertised alternate URL when retrieving its content.\n\nThe public `AGENTS.md` files contain product-use instructions. Repository\nmaintainer instructions and local workspace paths are not copied into them.\nArchives and prompt templates stay outside the automatic current context.\n\n### Discovery and semantic metadata\n\n| File or markup | Purpose | Status |\n| --- | --- | --- |\n| `/agent/discovery.json` | Coverage, format status, and retrieval links | Project-specific manifest |\n| `/.well-known/agent-skills/index.json` | Downloadable skills with content digests | [Discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), v0.2 |\n| `/.well-known/ai-catalog.json` | Catalog of documentation and skills | [AI Catalog draft](https://ai-catalog.io/spec/) |\n| `/robots.txt`, `/sitemap.xml` | Crawl policy and canonical page inventory | Standard crawler formats |\n| JSON-LD in each docs page | Product identity, documentation collection, technical article, breadcrumbs | [Schema.org](https://schema.org/TechArticle) |\n\nThe site serves documentation and downloadable skills. It does not host an\nA2A agent or a remote MCP server, so it publishes no A2A agent card, MCP server\ncard, or OAuth service metadata. Local MCP installation remains documented in\nthe product guides. Draft discovery support does not change product permissions.\n\nGitHub Pages serves explicit static files. This site does not promise Markdown\ncontent negotiation or custom HTTP discovery headers; use the linked `.md`\nURLs. JSON-LD and discovery links are present in the initial HTML and are\nupdated during navigation.\n\nThe family-wide files below combine the product documentation.\n\n| Need | URL |\n|------|-----|\n| Discovery manifest | `/agent/manifest.json` or `/docs.json` |\n| Full docs with markdown bodies | `/agent/docs.json` or `/agent-docs.json` |\n| Prompt registry | `/agent/prompts.json` or `/prompts.json` |\n| Combined agent context | `/agent/context.md` or `/agent-context.md` |\n| Combined raw docs | `/agent/bundles/all.md` or `/docs/all.md` |\n| Core onboarding bundle | `/agent/bundles/core.md` |\n| Daemon/API bundle | `/agent/bundles/daemon-api.md` |\n| Voice and prompts bundle | `/agent/bundles/voice.md` |\n| Install bundle | `/agent/bundles/install.md` |\n| Raw markdown mirror | `/agent/raw/docs/<slug>.md` or `/docs/markdown/<slug>.md` |\n\nPrompt files are mirrored at `/prompts/<id>.md` and\n`/docs/prompts/<id>.md`. Nested docs keep path-safe slugs, for example\n`docs/reference/install-agent.md` becomes\n`/agent/raw/docs/reference/install-agent.md`.\n\nLocal scripts can import the same convenience functions used by the\nbuild:\n\n```js\nimport {\n  collectMarkdownArtifacts,\n  getMarkdownDoc,\n  getPrompt,\n  buildAgentManifest,\n  buildPromptRegistry,\n  buildContextBundle,\n  writeAgentArtifacts,\n} from './apps/site/scripts/agent-docs.mjs'\n```\n\nRun `bun run docs:agent` to regenerate the static artifacts without\nrunning the full site build.\n\n## Placement contract\n\nAt the boundary, placements may be strings for convenience.\nInside the daemon, the canonical contract is typed.\n\nString shorthand:\n\n```json\n{ \"placement\": \"top-right\" }\n{ \"placement\": \"grid:3x2:2,0\" }\n```\n\nTyped placement objects:\n\n```json\n{ \"placement\": { \"kind\": \"tile\", \"value\": \"top-third\" } }\n{ \"placement\": { \"kind\": \"grid\", \"columns\": 3, \"rows\": 2, \"column\": 2, \"row\": 0 } }\n{ \"placement\": { \"kind\": \"fractions\", \"x\": 0.5, \"y\": 0, \"w\": 0.5, \"h\": 1 } }\n```\n\nThis lets voice stay natural while the executor stays explicit.\n\n## Choosing a surface\n\nUse the **daemon API** when you need:\n\n- typed receipts and traces\n- explicit targeting by `wid`, `session`, or `app`\n- composition across layers, windows, and optimization\n\nUse the **CLI** when you need:\n\n- one-shot shell execution\n- quick discovery from inside an agent terminal\n- compatibility with environments that already have `lats`\n\nUse **docs URLs** when an agent needs:\n\n- a citeable contract\n- a stable page to open in a browser or pass to another agent\n- quick onboarding without reading source\n\n## Examples\n\nNode.js:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/frontend' })\nawait daemonCall('windows.resolve', {\n  target: { kind: 'session', session: 'frontend-a1b2c3' },\n  placement: 'left'\n})\nawait daemonCall('actions.execute', {\n  type: 'window.place',\n  target: { kind: 'session', session: 'frontend-a1b2c3' },\n  args: { placement: { kind: 'tile', value: 'left' } }\n})\nawait daemonCall('layers.activate', { name: 'review', mode: 'launch' })\nawait daemonCall('spaces.optimize', { scope: 'visible', strategy: 'balanced' })\n```\n\nCLI:\n\n```bash\nlats call api.schema\nlats call windows.resolve '{\"target\":{\"kind\":\"session\",\"session\":\"frontend-a1b2c3\"},\"placement\":\"left\"}'\nlats call actions.execute '{\"type\":\"window.place\",\"target\":{\"kind\":\"frontmost\"},\"args\":{\"placement\":\"left\"}}'\nlats call windows.place '{\"session\":\"frontend-a1b2c3\",\"placement\":\"left\"}'\nlats call layers.activate '{\"name\":\"review\",\"mode\":\"launch\"}'\nlats call spaces.optimize '{\"scope\":\"visible\",\"strategy\":\"balanced\"}'\n```\n\n## Receipts and traceability\n\nMutating daemon calls should return structured receipts when possible.\nThe important fields for agents are:\n\n- resolved target\n- resolved placement / scope / mode\n- affected window IDs\n- computed plan and applied mutations\n- verification status\n- trace entries explaining why the daemon chose that path\n\nThis is what keeps voice, hands-off, and scripted execution scrutable.\n\n## Voice and hands-off\n\nVoice is not a separate execution system. It should compile into the\nsame canonical actions:\n\n- \"put Terminal in the upper third\" → `windows.place`\n- \"bring up review\" → `layers.activate`\n- \"make this nice\" → `spaces.optimize`\n\nThat keeps the interaction layer flexible while the executor stays\npredictable.\n\n## Assistant intelligence boundary\n\nAssistant planning lives in TypeScript where possible:\n\n- `bin/assistant-intelligence.ts` owns the intent catalog, prompt assembly,\n  local rule planner, desktop snapshot formatting, and plan normalization.\n- `bin/handsoff-worker.ts` and `bin/handsoff-infer.ts` call that module before\n  falling back to model inference.\n- Swift should remain the macOS execution layer: hotkeys, windows, AX/CG,\n  SkyLight, panels, and visual feedback.\n\nUse `lats assistant plan <text> --json` to inspect the TS planner without\nlaunching the app or mutating the desktop.\n",
      "content": "Lattices exposes the same execution model through these interfaces:\n\n- **Docs URLs** for discovery and citation\n- **CLI** for shell-based agents\n- **Daemon API** for typed programmatic control\n- **Voice / hands-off / HUD** as clients of the same execution layer\n\nThe rule is simple: agents should learn the **canonical action model**\nfirst, then choose the transport that fits the environment.\n\n## First check\n\nRun these read-only commands before any mutation. They do not move windows.\n\n```bash\nlats call api.schema        # every daemon method and its parameters\nlats call desktop.snapshot  # what is on screen now, with window ids\n```\n\nExpect a method list, then a snapshot of the current space. If either call\nfails, the usual cause is that the menu bar app, which hosts the daemon, is not\nrunning. Ask the user to start it with `lats app`. Then resolve a target with `windows.resolve` or `dryRun` before you\nplace a window. See [Discoverability](#discoverability).\n\n## Install the skills\n\n`lattices-dev/lattices` is the [skills.sh](https://skills.sh) catalog for the product\nfamily. Skills live under [`skills/`](https://github.com/lattices-dev/lattices/tree/main/skills).\n\n| Skill | Product |\n| --- | --- |\n| `lattices` | Workspace control, tiling, sessions, screen text |\n| `action` | Native observe / resolve / act / record |\n| `voice` | The `voice` domain: spoken commands, voice intents, and speech output through the Voice app |\n| `blink` | Spatial notes through the `blink` CLI |\n\n```bash\nnpx skills add lattices-dev/lattices --list\nnpx skills add lattices-dev/lattices --all --global --yes\nnpx skills add lattices-dev/lattices --skill lattices --global --yes\nnpx skills add lattices-dev/lattices --skill action --global --yes\nnpx skills add lattices-dev/lattices --skill voice --global --yes\nnpx skills add lattices-dev/lattices --skill blink --global --yes\n```\n\nThe `browser_*` tools come from `lats mcp`, not from a plugin. The\n`action-browser@lattices` plugin carries only the skill text. See\n[MCP](/lattices/docs/mcp).\n\nEach product owns its own work. [Concepts](/lattices/docs/concepts#where-commands-go)\nlists every `lats` command, the process that receives it, and the product\nthat does the work. Product docs: [Action](/action/docs/overview),\n[Canvas](/canvas/docs/overview), [Voice](/voice/docs/overview).\n\nTo install the Action product itself, run `lattices action install`. It\ndownloads the latest signed `Action.app` release to `/Applications`.\n`lattices action status` reports install, runtime, and agent state;\n`lattices action call <method>` talks to the Action agent without a monorepo\ncheckout. Action 0.4.0 and Lattices 0.15.1 use authenticated private IPC;\nearlier releases use the local agent port. Let the matching CLI discover the endpoint.\nSee [Processes and ports](/lattices/docs/concepts#processes-and-ports).\n\n## Action private layers\n\nAction private layers create virtual displays. Lattices workspace layers group\nwindows and use `layer.activate`; they do not create Action's private surface.\nFor private browser work, use Action MCP `layer_open { browser: true }`,\n`layer_snapshot`, `layer_click/type/js`, and `layer_close`.\n\nFirst run `lats action capabilities` and query `capabilities` plus `tools/list`\non the active Action MCP connection. Compare source/native fingerprints, check\n`safety.privateLayer`, and require host acceptance for desktop preservation.\nStop on quarantine; do not rebuild an installation target or fall back to a\nregular browser. A missing block is not proof of isolation. Reconnect MCP after\nupdates and repeat discovery. Matching a published version does not verify\nMCP or native feature parity.\n\nThe CLI offers layer lifecycle, snapshot, PiP, and recording. Browser flags,\ninput, navigation, and JavaScript require MCP. Raw Action WebSocket and the\nLattices daemon do not provide that orchestration. Follow the\n[Action skill](https://github.com/lattices-dev/lattices/blob/main/skills/action/SKILL.md)\nand [layer reference](/action/docs/agent-layer)\nfor leases, operator takeover, cleanup, and acceptance requirements.\n\n## Canonical mutations\n\nThese are the preferred action identifiers:\n\n| Action | Purpose | Preferred surface |\n|--------|---------|-------------------|\n| `windows.place` | Place a window or session using a typed placement spec | `actions.execute` |\n| `layers.activate` | Bring up a workspace layer with explicit activation mode | Daemon API |\n| `spaces.optimize` | Rebalance a set of windows using an explicit scope and strategy | Daemon API |\n| `tabs.stack` | Turn explicit or Hyperspace-selected windows into ephemeral cross-app tabs | Daemon API |\n| `tabs.layout` | Expand a live tab stack to grid or collapse it back to tabs | Daemon API |\n\nCompatibility wrappers still exist:\n\n| Legacy method | Canonical equivalent |\n|---------------|----------------------|\n| `windows.place` | `actions.execute` with `type=windows.place` |\n| `window.tile` | `windows.place` |\n| `layers.switch` | `layers.activate` |\n| `layout.distribute` | `spaces.optimize` with `scope=visible`, `strategy=balanced` |\n\n## Discoverability\n\nAgents should use these paths in order:\n\n1. **API schema**: `lats call api.schema`\n2. **Read-only workspace map and UI hygiene**: [/lattices/docs/workspace-map](/lattices/docs/workspace-map)\n3. **Daemon reference**: [/lattices/docs/api](/lattices/docs/api)\n4. **Agent guide**: [/lattices/docs/agents](/lattices/docs/agents)\n5. **Voice model**: [/lattices/docs/voice](/lattices/docs/voice)\n6. **Concepts / config**: [/lattices/docs/concepts](/lattices/docs/concepts), [/lattices/docs/config](/lattices/docs/config)\n\nUseful CLI discovery commands:\n\n```bash\nlats help\nlats call api.schema\nlats call desktop.snapshot\nlats map\nlats call terminals.capture '{\"session\":\"<live-session>\",\"lines\":40}'\nlats voice intents\n```\n\n`desktop.snapshot` is the structured “what is in front of the user?” read.\n`lats map` draws that same current-space view as ASCII. `terminals.capture`\nis exact tmux pane text, not OCR.\n\nFor window actions, resolve before mutating when target identity matters:\n\n```bash\nlats call windows.resolve '{\"target\":{\"kind\":\"session\",\"session\":\"frontend-a1b2c3\"},\"placement\":\"left\"}'\nlats call actions.execute '{\"type\":\"window.place\",\"target\":{\"kind\":\"session\",\"session\":\"frontend-a1b2c3\"},\"args\":{\"placement\":\"left\"},\"dryRun\":true}'\n```\n\n`windows.resolve` and `dryRun` return the resolved `wid`, app/title,\ntarget resolution path, display, and planned frame without moving the\nwindow.\n\nVerified `windows.place` receipts include `undoable: true` when Lattices\ncaptured a concrete window id and previous frame. Agents can restore the\nlatest undoable placement, or an entire request batch, through:\n\n```bash\nlats call history.undo '{}'\nlats call history.undo '{\"requestId\":\"req_...\"}'\nlats call history.undo '{\"receiptId\":\"exec_...\",\"dryRun\":true}'\n```\n\nUndo checks that the window is still where the original receipt left it\nbefore restoring the previous frame. Pass `force:true` only when the\ncaller intentionally wants to override a later manual move.\n\n## Agent documentation artifacts\n\nThe site build emits agent-first files in addition to rendered pages.\nUse these when an agent needs raw markdown, prompt text, or a compact\ncontext bundle without scraping HTML. Every docs page also has a\n**Use with an agent** control that copies the page or its product's docs.\n\nPrefer the product-scoped files. They hold only that product's current\nguides. `<product>` is `lattices`, `action`, `blink`, or `speech`.\n\n| Need | URL |\n|------|-----|\n| One product's index and first check | `/<product>/docs/llms.txt` |\n| One product's current docs, full text | `/<product>/docs/llms-full.txt` |\n| One product's docs as JSON | `/<product>/docs/agent-docs.json` |\n| One page as readable Markdown | `/<product>/docs/<slug>.md` or `/<product>/docs/<slug>/index.md` |\n| Exact Markdown source | `/<product>/docs/markdown/<slug>.md` |\n| Public product instructions | `/<product>/docs/AGENTS.md` |\n\nHTML pages advertise their Markdown representation with `rel=\"alternate\"`\nand the relevant product index with `rel=\"describedby\"`. The Markdown page\nlinks resolve to canonical web pages; source mirrors preserve the original\nbytes. Product indexes follow the [llms.txt proposal](https://llmstxt.org/).\n`llms-full.txt` is a complete-context convenience file, not a separate standard.\n\nPages named `agents` use `/agents/index.md` so their Markdown does not collide\nwith public `AGENTS.md` instructions on case-insensitive filesystems. Follow\nthe page's advertised alternate URL when retrieving its content.\n\nThe public `AGENTS.md` files contain product-use instructions. Repository\nmaintainer instructions and local workspace paths are not copied into them.\nArchives and prompt templates stay outside the automatic current context.\n\n### Discovery and semantic metadata\n\n| File or markup | Purpose | Status |\n| --- | --- | --- |\n| `/agent/discovery.json` | Coverage, format status, and retrieval links | Project-specific manifest |\n| `/.well-known/agent-skills/index.json` | Downloadable skills with content digests | [Discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), v0.2 |\n| `/.well-known/ai-catalog.json` | Catalog of documentation and skills | [AI Catalog draft](https://ai-catalog.io/spec/) |\n| `/robots.txt`, `/sitemap.xml` | Crawl policy and canonical page inventory | Standard crawler formats |\n| JSON-LD in each docs page | Product identity, documentation collection, technical article, breadcrumbs | [Schema.org](https://schema.org/TechArticle) |\n\nThe site serves documentation and downloadable skills. It does not host an\nA2A agent or a remote MCP server, so it publishes no A2A agent card, MCP server\ncard, or OAuth service metadata. Local MCP installation remains documented in\nthe product guides. Draft discovery support does not change product permissions.\n\nGitHub Pages serves explicit static files. This site does not promise Markdown\ncontent negotiation or custom HTTP discovery headers; use the linked `.md`\nURLs. JSON-LD and discovery links are present in the initial HTML and are\nupdated during navigation.\n\nThe family-wide files below combine the product documentation.\n\n| Need | URL |\n|------|-----|\n| Discovery manifest | `/agent/manifest.json` or `/docs.json` |\n| Full docs with markdown bodies | `/agent/docs.json` or `/agent-docs.json` |\n| Prompt registry | `/agent/prompts.json` or `/prompts.json` |\n| Combined agent context | `/agent/context.md` or `/agent-context.md` |\n| Combined raw docs | `/agent/bundles/all.md` or `/docs/all.md` |\n| Core onboarding bundle | `/agent/bundles/core.md` |\n| Daemon/API bundle | `/agent/bundles/daemon-api.md` |\n| Voice and prompts bundle | `/agent/bundles/voice.md` |\n| Install bundle | `/agent/bundles/install.md` |\n| Raw markdown mirror | `/agent/raw/docs/<slug>.md` or `/docs/markdown/<slug>.md` |\n\nPrompt files are mirrored at `/prompts/<id>.md` and\n`/docs/prompts/<id>.md`. Nested docs keep path-safe slugs, for example\n`docs/reference/install-agent.md` becomes\n`/agent/raw/docs/reference/install-agent.md`.\n\nLocal scripts can import the same convenience functions used by the\nbuild:\n\n```js\nimport {\n  collectMarkdownArtifacts,\n  getMarkdownDoc,\n  getPrompt,\n  buildAgentManifest,\n  buildPromptRegistry,\n  buildContextBundle,\n  writeAgentArtifacts,\n} from './apps/site/scripts/agent-docs.mjs'\n```\n\nRun `bun run docs:agent` to regenerate the static artifacts without\nrunning the full site build.\n\n## Placement contract\n\nAt the boundary, placements may be strings for convenience.\nInside the daemon, the canonical contract is typed.\n\nString shorthand:\n\n```json\n{ \"placement\": \"top-right\" }\n{ \"placement\": \"grid:3x2:2,0\" }\n```\n\nTyped placement objects:\n\n```json\n{ \"placement\": { \"kind\": \"tile\", \"value\": \"top-third\" } }\n{ \"placement\": { \"kind\": \"grid\", \"columns\": 3, \"rows\": 2, \"column\": 2, \"row\": 0 } }\n{ \"placement\": { \"kind\": \"fractions\", \"x\": 0.5, \"y\": 0, \"w\": 0.5, \"h\": 1 } }\n```\n\nThis lets voice stay natural while the executor stays explicit.\n\n## Choosing a surface\n\nUse the **daemon API** when you need:\n\n- typed receipts and traces\n- explicit targeting by `wid`, `session`, or `app`\n- composition across layers, windows, and optimization\n\nUse the **CLI** when you need:\n\n- one-shot shell execution\n- quick discovery from inside an agent terminal\n- compatibility with environments that already have `lats`\n\nUse **docs URLs** when an agent needs:\n\n- a citeable contract\n- a stable page to open in a browser or pass to another agent\n- quick onboarding without reading source\n\n## Examples\n\nNode.js:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/frontend' })\nawait daemonCall('windows.resolve', {\n  target: { kind: 'session', session: 'frontend-a1b2c3' },\n  placement: 'left'\n})\nawait daemonCall('actions.execute', {\n  type: 'window.place',\n  target: { kind: 'session', session: 'frontend-a1b2c3' },\n  args: { placement: { kind: 'tile', value: 'left' } }\n})\nawait daemonCall('layers.activate', { name: 'review', mode: 'launch' })\nawait daemonCall('spaces.optimize', { scope: 'visible', strategy: 'balanced' })\n```\n\nCLI:\n\n```bash\nlats call api.schema\nlats call windows.resolve '{\"target\":{\"kind\":\"session\",\"session\":\"frontend-a1b2c3\"},\"placement\":\"left\"}'\nlats call actions.execute '{\"type\":\"window.place\",\"target\":{\"kind\":\"frontmost\"},\"args\":{\"placement\":\"left\"}}'\nlats call windows.place '{\"session\":\"frontend-a1b2c3\",\"placement\":\"left\"}'\nlats call layers.activate '{\"name\":\"review\",\"mode\":\"launch\"}'\nlats call spaces.optimize '{\"scope\":\"visible\",\"strategy\":\"balanced\"}'\n```\n\n## Receipts and traceability\n\nMutating daemon calls should return structured receipts when possible.\nThe important fields for agents are:\n\n- resolved target\n- resolved placement / scope / mode\n- affected window IDs\n- computed plan and applied mutations\n- verification status\n- trace entries explaining why the daemon chose that path\n\nThis is what keeps voice, hands-off, and scripted execution scrutable.\n\n## Voice and hands-off\n\nVoice is not a separate execution system. It should compile into the\nsame canonical actions:\n\n- \"put Terminal in the upper third\" → `windows.place`\n- \"bring up review\" → `layers.activate`\n- \"make this nice\" → `spaces.optimize`\n\nThat keeps the interaction layer flexible while the executor stays\npredictable.\n\n## Assistant intelligence boundary\n\nAssistant planning lives in TypeScript where possible:\n\n- `bin/assistant-intelligence.ts` owns the intent catalog, prompt assembly,\n  local rule planner, desktop snapshot formatting, and plan normalization.\n- `bin/handsoff-worker.ts` and `bin/handsoff-infer.ts` call that module before\n  falling back to model inference.\n- Swift should remain the macOS execution layer: hotkeys, windows, AX/CG,\n  SkyLight, panels, and visual feedback.\n\nUse `lats assistant plan <text> --json` to inspect the TS planner without\nlaunching the app or mutating the desktop."
    },
    {
      "id": "lattices:api",
      "slug": "api",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Agent API",
      "description": "WebSocket API reference for programmatic control of lattices",
      "order": 5,
      "sourcePath": "docs/api.md",
      "url": "/lattices/docs/api",
      "rawUrl": "/lattices/docs/markdown/api.md",
      "markdownUrl": "/lattices/docs/api.md",
      "headings": [
        {
          "depth": 2,
          "text": "Quick start",
          "anchor": "quick-start"
        },
        {
          "depth": 3,
          "text": "Private connection",
          "anchor": "private-connection"
        },
        {
          "depth": 2,
          "text": "Wire protocol",
          "anchor": "wire-protocol"
        },
        {
          "depth": 3,
          "text": "Request",
          "anchor": "request"
        },
        {
          "depth": 3,
          "text": "Response",
          "anchor": "response"
        },
        {
          "depth": 3,
          "text": "Errors",
          "anchor": "errors"
        },
        {
          "depth": 3,
          "text": "Method names and aliases",
          "anchor": "method-names-and-aliases"
        },
        {
          "depth": 3,
          "text": "Connection lifecycle",
          "anchor": "connection-lifecycle"
        },
        {
          "depth": 3,
          "text": "Remote hosts",
          "anchor": "remote-hosts"
        },
        {
          "depth": 2,
          "text": "Node.js client",
          "anchor": "node-js-client"
        },
        {
          "depth": 3,
          "text": "daemonCall(method, params?, timeoutMs?)",
          "anchor": "daemoncall-method-params-timeoutms"
        },
        {
          "depth": 3,
          "text": "isDaemonRunning()",
          "anchor": "isdaemonrunning"
        },
        {
          "depth": 2,
          "text": "TypeScript SDK facade",
          "anchor": "typescript-sdk-facade"
        },
        {
          "depth": 3,
          "text": "Error handling",
          "anchor": "error-handling"
        },
        {
          "depth": 2,
          "text": "Runs And Capture",
          "anchor": "runs-and-capture"
        },
        {
          "depth": 4,
          "text": "capture.screenshotWindow",
          "anchor": "capture-screenshotwindow"
        },
        {
          "depth": 4,
          "text": "capture.still",
          "anchor": "capture-still"
        },
        {
          "depth": 4,
          "text": "capture.stream.",
          "anchor": "capture-stream"
        },
        {
          "depth": 4,
          "text": "capture.screenshotDisplay",
          "anchor": "capture-screenshotdisplay"
        },
        {
          "depth": 4,
          "text": "capture.screenshotRegion",
          "anchor": "capture-screenshotregion"
        },
        {
          "depth": 4,
          "text": "capture.zoomArtifact",
          "anchor": "capture-zoomartifact"
        },
        {
          "depth": 4,
          "text": "vision.analyzeWindow / vision.analyzeArtifact",
          "anchor": "vision-analyzewindow-vision-analyzeartifact"
        },
        {
          "depth": 4,
          "text": "Computer Action Treatments",
          "anchor": "computer-action-treatments"
        },
        {
          "depth": 4,
          "text": "Background-Safety Receipts",
          "anchor": "background-safety-receipts"
        },
        {
          "depth": 4,
          "text": "computer.prepare",
          "anchor": "computer-prepare"
        },
        {
          "depth": 4,
          "text": "computer.windowState",
          "anchor": "computer-windowstate"
        },
        {
          "depth": 4,
          "text": "computer.elementAction",
          "anchor": "computer-elementaction"
        },
        {
          "depth": 4,
          "text": "computer.typeElement / computer.setValue",
          "anchor": "computer-typeelement-computer-setvalue"
        },
        {
          "depth": 4,
          "text": "computer.pressKey / computer.hotkey",
          "anchor": "computer-presskey-computer-hotkey"
        },
        {
          "depth": 4,
          "text": "computer.focusWindow",
          "anchor": "computer-focuswindow"
        },
        {
          "depth": 4,
          "text": "computer.showCursor",
          "anchor": "computer-showcursor"
        },
        {
          "depth": 4,
          "text": "computer.launchApp",
          "anchor": "computer-launchapp"
        },
        {
          "depth": 4,
          "text": "computer.typeWindowText",
          "anchor": "computer-typewindowtext"
        },
        {
          "depth": 4,
          "text": "computer.click / computer.doubleClick / computer.rightClick",
          "anchor": "computer-click-computer-doubleclick-computer-rightclick"
        },
        {
          "depth": 4,
          "text": "computer.scroll / computer.drag",
          "anchor": "computer-scroll-computer-drag"
        },
        {
          "depth": 4,
          "text": "computer.verify",
          "anchor": "computer-verify"
        },
        {
          "depth": 4,
          "text": "browser.getText / browser.queryDom / browser.executeJavascript",
          "anchor": "browser-gettext-browser-querydom-browser-executejavascript"
        },
        {
          "depth": 4,
          "text": "computer.demoScout",
          "anchor": "computer-demoscout"
        },
        {
          "depth": 4,
          "text": "computer.typeText",
          "anchor": "computer-typetext"
        },
        {
          "depth": 4,
          "text": "computer.demoTerminal",
          "anchor": "computer-demoterminal"
        },
        {
          "depth": 2,
          "text": "Overlay UI",
          "anchor": "overlay-ui"
        },
        {
          "depth": 4,
          "text": "overlay.publish",
          "anchor": "overlay-publish"
        },
        {
          "depth": 4,
          "text": "overlay.actor.publish",
          "anchor": "overlay-actor-publish"
        },
        {
          "depth": 4,
          "text": "overlay.actor.moveTo",
          "anchor": "overlay-actor-moveto"
        },
        {
          "depth": 4,
          "text": "overlay.actor.hud",
          "anchor": "overlay-actor-hud"
        },
        {
          "depth": 4,
          "text": "overlay.actor.visibility",
          "anchor": "overlay-actor-visibility"
        },
        {
          "depth": 3,
          "text": "Static HUD Manifests",
          "anchor": "static-hud-manifests"
        },
        {
          "depth": 2,
          "text": "System",
          "anchor": "system"
        },
        {
          "depth": 4,
          "text": "deck.manifest",
          "anchor": "deck-manifest"
        },
        {
          "depth": 4,
          "text": "deck.snapshot",
          "anchor": "deck-snapshot"
        },
        {
          "depth": 4,
          "text": "deck.perform",
          "anchor": "deck-perform"
        },
        {
          "depth": 4,
          "text": "solo.status, solo.enter, solo.exit, solo.toggle",
          "anchor": "solo-status-solo-enter-solo-exit-solo-toggle"
        },
        {
          "depth": 4,
          "text": "stage.status, stage.enter, stage.exit, stage.toggle, stage.cycle",
          "anchor": "stage-status-stage-enter-stage-exit-stage-toggle-stage-cycle"
        },
        {
          "depth": 4,
          "text": "daemon.status",
          "anchor": "daemon-status"
        },
        {
          "depth": 4,
          "text": "api.schema",
          "anchor": "api-schema"
        },
        {
          "depth": 4,
          "text": "diagnostics.list",
          "anchor": "diagnostics-list"
        },
        {
          "depth": 2,
          "text": "Mouse & Input",
          "anchor": "mouse-input"
        },
        {
          "depth": 2,
          "text": "Windows & Spaces",
          "anchor": "windows-spaces"
        },
        {
          "depth": 4,
          "text": "desktop.snapshot",
          "anchor": "desktop-snapshot"
        },
        {
          "depth": 4,
          "text": "windows.list",
          "anchor": "windows-list"
        },
        {
          "depth": 4,
          "text": "windows.get",
          "anchor": "windows-get"
        },
        {
          "depth": 4,
          "text": "windows.search",
          "anchor": "windows-search"
        },
        {
          "depth": 4,
          "text": "windows.preview",
          "anchor": "windows-preview"
        },
        {
          "depth": 4,
          "text": "spaces.list",
          "anchor": "spaces-list"
        },
        {
          "depth": 4,
          "text": "windows.place",
          "anchor": "windows-place"
        },
        {
          "depth": 4,
          "text": "windows.pick",
          "anchor": "windows-pick"
        },
        {
          "depth": 4,
          "text": "windows.focus",
          "anchor": "windows-focus"
        },
        {
          "depth": 4,
          "text": "windows.move",
          "anchor": "windows-move"
        },
        {
          "depth": 4,
          "text": "spaces.optimize",
          "anchor": "spaces-optimize"
        },
        {
          "depth": 4,
          "text": "layout.distribute",
          "anchor": "layout-distribute"
        },
        {
          "depth": 2,
          "text": "Sessions",
          "anchor": "sessions"
        },
        {
          "depth": 4,
          "text": "tmux.list",
          "anchor": "tmux-list"
        },
        {
          "depth": 4,
          "text": "sessions.launch",
          "anchor": "sessions-launch"
        },
        {
          "depth": 4,
          "text": "sessions.kill",
          "anchor": "sessions-kill"
        },
        {
          "depth": 4,
          "text": "sessions.detach",
          "anchor": "sessions-detach"
        },
        {
          "depth": 4,
          "text": "sessions.sync",
          "anchor": "sessions-sync"
        },
        {
          "depth": 4,
          "text": "sessions.restart",
          "anchor": "sessions-restart"
        },
        {
          "depth": 2,
          "text": "Projects & Layers",
          "anchor": "projects-layers"
        },
        {
          "depth": 4,
          "text": "projects.list",
          "anchor": "projects-list"
        },
        {
          "depth": 4,
          "text": "projects.scan",
          "anchor": "projects-scan"
        },
        {
          "depth": 4,
          "text": "layers.list",
          "anchor": "layers-list"
        },
        {
          "depth": 4,
          "text": "layers.exit",
          "anchor": "layers-exit"
        },
        {
          "depth": 4,
          "text": "layers.reveal",
          "anchor": "layers-reveal"
        },
        {
          "depth": 4,
          "text": "layers.members",
          "anchor": "layers-members"
        },
        {
          "depth": 4,
          "text": "Editing layers",
          "anchor": "editing-layers"
        },
        {
          "depth": 4,
          "text": "layers.activate",
          "anchor": "layers-activate"
        },
        {
          "depth": 4,
          "text": "layers.switch",
          "anchor": "layers-switch"
        },
        {
          "depth": 4,
          "text": "groups.launch",
          "anchor": "groups-launch"
        },
        {
          "depth": 4,
          "text": "groups.kill",
          "anchor": "groups-kill"
        },
        {
          "depth": 4,
          "text": "Live tab stacks",
          "anchor": "live-tab-stacks"
        },
        {
          "depth": 2,
          "text": "Processes & Terminals",
          "anchor": "processes-terminals"
        },
        {
          "depth": 4,
          "text": "processes.list",
          "anchor": "processes-list"
        },
        {
          "depth": 4,
          "text": "processes.tree",
          "anchor": "processes-tree"
        },
        {
          "depth": 4,
          "text": "terminals.capture",
          "anchor": "terminals-capture"
        },
        {
          "depth": 4,
          "text": "terminals.list",
          "anchor": "terminals-list"
        },
        {
          "depth": 4,
          "text": "terminals.search",
          "anchor": "terminals-search"
        },
        {
          "depth": 2,
          "text": "OCR",
          "anchor": "ocr"
        },
        {
          "depth": 4,
          "text": "ocr.snapshot",
          "anchor": "ocr-snapshot"
        },
        {
          "depth": 4,
          "text": "ocr.search",
          "anchor": "ocr-search"
        },
        {
          "depth": 4,
          "text": "ocr.history",
          "anchor": "ocr-history"
        },
        {
          "depth": 4,
          "text": "ocr.scan",
          "anchor": "ocr-scan"
        },
        {
          "depth": 2,
          "text": "Events",
          "anchor": "events"
        },
        {
          "depth": 4,
          "text": "windows.changed",
          "anchor": "windows-changed"
        },
        {
          "depth": 4,
          "text": "tmux.changed",
          "anchor": "tmux-changed"
        },
        {
          "depth": 4,
          "text": "layer.switched",
          "anchor": "layer-switched"
        },
        {
          "depth": 4,
          "text": "ocr.scanComplete",
          "anchor": "ocr-scancomplete"
        },
        {
          "depth": 4,
          "text": "processes.changed",
          "anchor": "processes-changed"
        },
        {
          "depth": 2,
          "text": "Agent integration",
          "anchor": "agent-integration"
        },
        {
          "depth": 3,
          "text": "CLAUDE.md snippet",
          "anchor": "claude-md-snippet"
        },
        {
          "depth": 3,
          "text": "Multi-agent orchestration",
          "anchor": "multi-agent-orchestration"
        },
        {
          "depth": 3,
          "text": "Reactive event pattern",
          "anchor": "reactive-event-pattern"
        },
        {
          "depth": 3,
          "text": "Health check before use",
          "anchor": "health-check-before-use"
        },
        {
          "depth": 3,
          "text": "Pi extension",
          "anchor": "pi-extension"
        },
        {
          "depth": 3,
          "text": "Machines and visiting cursor",
          "anchor": "machines-and-visiting-cursor"
        },
        {
          "depth": 2,
          "text": "Machines and displays",
          "anchor": "machines-and-displays"
        }
      ],
      "tokensEstimate": 21496,
      "frontmatter": {
        "title": "Agent API",
        "description": "WebSocket API reference for programmatic control of lattices",
        "order": 5
      },
      "markdown": "---\ntitle: Agent API\ndescription: WebSocket API reference for programmatic control of lattices\norder: 5\n---\n\nLattices 0.15.1 and later serve the agent API over authenticated WebSocket on\n`~/.lattices/run/lattices.sock`. The same app hosts voice input on\n`~/.lattices/run/voice.sock` (see [Voice](/lattices/docs/voice)).\n~120 RPC methods and real-time events. Prefer `desktop.snapshot` when you\nneed to know what is in front of the user, and `lats map` when you want\nthat same state drawn as ASCII.\n\n## Quick start\n\n1. Launch the server (it starts with the menu bar app):\n\n```bash\nlats app\n```\n\n2. Check that it's running:\n\n```bash\nlats daemon status\n```\n\n3. Call a method from Node.js:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\nconst windows = await daemonCall('windows.list')\nconsole.log(windows) // [{ wid, app, title, frame, ... }, ...]\n```\n\nFrom another language, use a WebSocket client that supports Unix sockets and\nthe authentication headers below.\n\n### Private connection\n\nInstall the Lattices app and CLI at version 0.15.2 together. Older clients\nthat connect directly to TCP must migrate to the private transport.\n\n- The daemon listens on a private Unix socket, `~/.lattices/run/lattices.sock`,\n  and writes a capability token to `~/.lattices/run/lattices.token`. The\n  directory is mode `0700`; both files are mode `0600`.\n- Directory and token permissions restrict local access to the same user.\n  The macOS server also checks the peer's user ID. The WebSocket upgrade must\n  use `Host: localhost`, carry the token in `x-lattices-token`, and send no\n  `Origin` header.\n- `lats` and `daemonCall()` find the socket and token for you. Code above keeps\n  working. A plain `websocat ws://127.0.0.1:9399` does not: no TCP port is\n  open by default. The macOS app can enable loopback TCP with\n  `LATTICES_ALLOW_LOOPBACK_TCP=1`; that port still requires the token. The\n  Linux host has no TCP compatibility listener.\n- The shared client sends local tokens over private IPC by default. An explicit\n  TCP target, including a loopback address that could be an SSH tunnel, receives\n  no local token unless loopback compatibility is explicitly enabled.\n  The CLI and Pi client require `LATTICES_ALLOW_LOOPBACK_TCP=1`; programmatic\n  `daemonCallTo()` callers can deliberately supply `tokenFile` for a loopback\n  endpoint. Non-loopback targets never receive local tokens.\n- Remote clients reach a host only through the opt-in tailnet gateway, after a\n  person on that host approves the pairing. See\n  [Remote hosts](#remote-hosts).\n\nSee [Private IPC and the gateway](/lattices/docs/concepts#private-ipc-and-the-gateway)\nfor every product's socket.\n\n## Wire protocol\n\nlattices uses a JSON-RPC-style protocol over authenticated WebSocket on the private socket.\n\n### Request\n\n```json\n{\n  \"id\": \"unique-string\",\n  \"method\": \"windows.list\",\n  \"params\": { \"wid\": 1234 }\n}\n```\n\n| Field    | Type    | Required | Description                          |\n|----------|---------|----------|--------------------------------------|\n| `id`     | string  | yes      | Caller-chosen ID, echoed in response |\n| `method` | string  | yes      | Method name (see below)              |\n| `params` | object  | no       | Method-specific parameters           |\n\n### Response\n\n```json\n{\n  \"id\": \"unique-string\",\n  \"result\": [ ... ],\n  \"error\": null\n}\n```\n\n| Field    | Type           | Description                                  |\n|----------|----------------|----------------------------------------------|\n| `id`     | string         | Echoed from request                          |\n| `result` | any \\| null    | Method return value (null on error)          |\n| `error`  | string \\| null | Error message (null on success)              |\n\n### Errors\n\n| Error           | Meaning                              |\n|-----------------|--------------------------------------|\n| Unknown method  | The `method` string is not recognized |\n| Missing parameter | A required param was not provided   |\n| Not found       | The referenced resource doesn't exist |\n\n### Method names and aliases\n\nMethods are named `<domain>.<verb>`, with plural domains: `windows.place`,\n`sessions.launch`, `layers.switch` ([LAT-012](proposals/LAT-012-domain-api.md)).\nThe pre-LAT-012 names still work as aliases, and `api.schema` lists them under\n`aliases` (old name → new name):\n\n| Old | New |\n|-----|-----|\n| `window.focus/move/place/present/resolve` | `windows.focus/move/place/present/resolve` |\n| `window.pick.start` | `windows.pick` |\n| `window.tile` | `windows.place` (`position` becomes `placement`) |\n| `layer.activate`, `layer.switch` | `layers.activate`, `layers.switch` |\n| `space.optimize` | `spaces.optimize` |\n| `session.*` | `sessions.*` |\n| `group.launch/kill` | `groups.launch/kill` |\n| `tabStacks.list/add/select/layout` | `tabs.list/add/select/layout` |\n| `tabStacks.create`, `tabStacks.delete` | `tabs.stack`, `tabs.unstack` |\n| `lattices.search` | `search.query` |\n| `focus.status/enter/exit/toggle` (Focus Mode) | `solo.status/enter/exit/toggle` |\n| `tmux.sessions`, `tmux.inventory` | `tmux.list`, `tmux.list({ includeOrphans: true })` |\n| `ocr.recent` | `ocr.history` without `wid` |\n| `intents.execute` | `intents.run` |\n| `actions.history`, `actions.undo` | `history.list`, `history.undo` |\n\nAction receipts keep their own `type` values (`window.place`, `window.move`);\nthose name the action, not the RPC method. The Node.js `daemonCall()` client\nand LatticesKit retry a new name under its old one when an older daemon\nanswers `Unknown method`.\n\n### Connection lifecycle\n\n- The server starts when the menu bar app launches and stops when it quits.\n- Every connection requires the local token at the WebSocket handshake; see\n  [Private connection](#private-connection).\n- The Node.js `daemonCall()` client opens a fresh connection per call and\n  closes it when the response arrives. For event subscriptions, hold the\n  connection open (see [Reactive event pattern](#agent-integration)).\n- If the server restarts (e.g. after `lats app restart`), existing\n  connections are dropped. Clients should reconnect and treat it as\n  stateless. There is no session resumption.\n\n### Remote hosts\n\nIn 0.15.1, neither the macOS app nor `lattices-host` opens a remote listener.\nRemote access uses the separate gateway, which the host user starts explicitly:\n\n```bash\n# On the host\nlats gateway start --tailnet\n\n# On the client: request access, then wait for approval on the host\nlats --host HOST pair --read-only\n# A viewer also needs screen contents (screenshots, streams, OCR):\nlats --host HOST pair --read-only --screen\n# Or request exactly these methods instead of the read-only scope:\nlats --host HOST pair --grant lattices:windows.list --grant speech:speech.enqueue\n\n# On the host: check the fingerprint and grants, then approve\nlats gateway clients\nlats gateway approve CLIENT_ID\n```\n\nThe gateway binds only the host's Tailscale address, on port 9399 by default.\nCore method names do not change. Methods of other products go through\n`gateway.call` with `{ \"product\": \"speech\", \"method\": \"speech.enqueue\", \"params\": { \"text\": \"Build finished\" } }`.\nEach grant names one exact `product:method`, and only reviewed methods can be\ngranted. Re-pairing with the same client key adds approved grants to the existing\nset. To reduce access, revoke the client and pair again. `--grant` cannot be\ncombined with `--read-only`, `--drive`, `--screen` or `--shell`.\n\nCore scopes are `read`, `screen` (screenshots, streams, OCR, terminal\ncontents), `act`, `drive` (desktop input) and `shell` (typing text and\nlaunching commands). `--read-only` requests `read`, plain `pair` requests\n`read`, `screen` and `act`, and `--drive` requests all five. Stored grants are\nexact methods, so clients paired before `screen` and `shell` were split out keep\nwhat they had. Clients paired directly with a host before this\nchange must pair again with the gateway.\n\nThe native Machines/Hosts view reuses this CLI gateway pairing and signs its\nrequests. Pairing a companion device alone does not authorize remote host\nstatus or screens. The Pi extension and Swift SDK do not add gateway signatures\nto explicit remote endpoints; a host or URL override alone is not a paired\ngateway client.\n\nRemote core event subscriptions are limited to `windows.changed`,\n`spaces.changed`, `layers.changed` and `host.healthChanged`. A client needs\n`lattices:events.subscribe` and the corresponding read grant (`windows.list`,\n`spaces.list`, `layers.list` or `daemon.status`). Unsubscribing requires\n`lattices:events.unsubscribe`. Capture stream lifecycle and activity events go\ndirectly to the connection that owns the stream; see `capture.stream.*` below.\nOther event streams stay local.\n\n## Node.js client\n\nlattices ships a zero-dependency WebSocket client that works with\nNode.js 18+. It handles connection, framing, and request/response\nmatching internally.\n\n### `daemonCall(method, params?, timeoutMs?)`\n\nSend an RPC call and await the response.\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Read-only\nconst status = await daemonCall('daemon.status')\nconst windows = await daemonCall('windows.list')\nconst win = await daemonCall('windows.get', { wid: 1234 })\n\n// Mutations\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/myapp' })\nawait daemonCall('windows.place', {\n  session: 'myapp-a1b2c3',\n  placement: { kind: 'tile', value: 'left' }\n})\n\n// Custom timeout (default: 3000ms)\nawait daemonCall('projects.scan', null, 10000)\n```\n\n**Returns** the `result` field from the response.\n**Throws** if the server returns an error, the connection fails, or the timeout is reached.\n\n### `isDaemonRunning()`\n\nCheck if the server is reachable.\n\n```js\nimport { isDaemonRunning } from '@lattices/lattices'\n\nif (await isDaemonRunning()) {\n  console.log('daemon is up')\n}\n```\n\nReturns `true` if `daemon.status` responds within 1 second.\n\n## TypeScript SDK facade\n\nThe CLI is a human/debug surface. Product code and agents should prefer the\ntyped SDK facade, which validates params with dependency-free schemas and calls\nthe same daemon methods directly.\n\n```ts\nimport { cua } from '@lattices/lattices/cua'\n\nawait cua.magicCursor({\n  app: 'Scout',\n  xRatio: 0.52,\n  yRatio: 0.91,\n  text: 'What are the most important docs in this project, and what would an agent say after reading them?',\n  treatment: 'execute',\n  trail: 'comet',\n  motion: 'rush',\n  trajectory: 'overshoot',\n  glow: 'halo',\n  idle: 'wiggle',\n  edge: 'ripple',\n})\n\nawait cua.click({\n  app: 'Scout',\n  xRatio: 0.74,\n  yRatio: 0.95,\n  transport: 'ax',\n  axLabel: 'Send',\n  noFocus: true,\n  treatment: 'execute',\n})\n```\n\n`@lattices/lattices/cua` exposes this typed CUA module. Existing imports from\n`@arach/lattices/cua` and `@lattices/cli/cua` remain supported.\n\n### Error handling\n\n`daemonCall` throws on errors — always wrap calls in try/catch:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\ntry {\n  await daemonCall('sessions.launch', { path: '/nonexistent' })\n} catch (err) {\n  // err.message is one of:\n  //   \"Not found\"              — resource doesn't exist\n  //   \"Missing parameter: ...\" — required param missing\n  //   \"Unknown method: ...\"    — bad method name\n  //   \"Daemon request timed out\" — no response within timeout\n  //   ECONNREFUSED             — daemon not running\n  console.error('Daemon error:', err.message)\n}\n```\n\n---\n\n## Runs And Capture\n\nRuns are local executions that produce trace events and artifacts. Capture\nmethods write into the Lattices run store under\n`~/Library/Application Support/Lattices/Runs/`.\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `runs.create` | write | Create a run record and artifact directory |\n| `runs.list` | read | List recent runs |\n| `runs.get` | read | Inspect one run, including artifacts and trace events |\n| `runs.artifacts` | read | List artifacts for one run |\n| `capture.screenshotWindow` | write | Capture a window screenshot as a run artifact |\n| `capture.screenshotDisplay` | write | Capture one complete display as a run artifact and optional clipboard image |\n| `capture.still` | read | One inline JPEG of a display, window or region for remote viewers; nothing is saved |\n| `capture.stream.start` | read | Stream a display, window or region as H.264 binary frames on this connection |\n| `capture.stream.ack` | read | Acknowledge frames up to `seq`; the host keeps at most 4 unacknowledged |\n| `capture.stream.keyframe` | read | Make the stream's next frame a keyframe |\n| `capture.stream.stop` | read | Stop a stream |\n| `capture.screenshotRegion` | write | Capture a screen region as a run artifact |\n| `capture.zoomArtifact` | write | Crop and zoom an image artifact into a linked artifact |\n| `vision.analyzeWindow` | write | Capture a window and run local OCR-based visual analysis |\n| `vision.analyzeArtifact` | write | Analyze an existing image artifact or path with local OCR |\n| `computer.prepare` | write | Resolve and optionally capture a terminal target without mutating it |\n| `computer.windowState` | write | Inspect a target window's AX tree and optionally write screenshot/run artifacts |\n| `computer.elementAction` | write | Stage or execute an AX action against a snapshot element id |\n| `computer.typeElement` | write | Stage or execute AXValue text insertion against a snapshot element id |\n| `computer.setValue` | write | Alias for replacing a snapshot element's AXValue |\n| `computer.pressKey` | write | Stage or execute one key press against an explicit target window |\n| `computer.hotkey` | write | Stage or execute a keyboard shortcut against an explicit target window |\n| `computer.focusWindow` | write | Resolve, capture, focus, and verify a target window |\n| `computer.showCursor` | write | Show a visible cursor appearance and record it as a run |\n| `computer.launchApp` | write | Launch or focus a normal macOS app and record the run |\n| `computer.typeWindowText` | write | Type or paste into a normal app window, optionally after a click |\n| `computer.click` | write | Stage or execute a window-relative click target; prefers no-focus `AXPress` in auto/ax transport |\n| `computer.doubleClick` | write | Stage or execute a pointer double-click target |\n| `computer.rightClick` | write | Stage or execute a right-click/context-click target |\n| `computer.scroll` | write | Stage or execute scroll wheel input |\n| `computer.drag` | write | Stage or execute a pointer drag between two points |\n| `computer.verify` | write | Verify OCR, AX, or artifact-change expectations |\n| `computer.demoScout` | write | Scout warm-up run for memo/demo recording |\n| `computer.typeText` | write | Insert text into a safe terminal using the least intrusive transport |\n| `computer.demoTerminal` | write | Compatibility wrapper for a bounded terminal text action |\n| `browser.getText` | read | Read visible browser text through Accessibility |\n| `browser.queryDom` | read | Query DOM summaries with explicit browser automation consent |\n| `browser.executeJavascript` | write | Stage or execute browser JavaScript with explicit consent |\n\n#### `capture.screenshotWindow`\n\nCapture a window as a PNG artifact. If no target is provided, Lattices captures\nthe frontmost non-Lattices window.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target CGWindowID |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title filter for `app`, or run title |\n| `runId` | string | no | Existing run to append to |\n| `source` | string | no | Calling surface label |\n| `filename` | string | no | Optional artifact filename |\n\n```js\nawait daemonCall('capture.screenshotWindow', { source: 'agent' })\n\nawait daemonCall('capture.screenshotWindow', {\n  session: 'frontend-a1b2c3',\n  filename: 'before-layout.png'\n})\n```\n\n#### `capture.still`\n\nReturn one compressed frame inline. Other machines pull these one at a time to\nshow this Mac in their remote viewer. Nothing is written to disk or the\nclipboard. Pass a window or a region, or neither for a display.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `displayIndex` | int | no | Display to capture; defaults to the display under the pointer |\n| `wid` | int | no | Window to capture instead of a display |\n| `x`, `y`, `width`, `height` | double | no | Region to capture, in layout points |\n| `maxWidth` | int | no | Largest pixel width to return; defaults to `1600` |\n| `quality` | int | no | JPEG quality from 20 to 95; defaults to `70` |\n\nThe result has `format`, `width`, `height`, base64 `data`, and `frame`: the\ncaptured rect in the same coordinates that `computer.*` input takes.\n\n#### `capture.stream.*`\n\nPush H.264 of a display, window or region as binary WebSocket messages on the\nconnection that asked, while a viewer is on screen. `capture.still` stays for\nthumbnails, idle views and hosts without streaming. `host.describe` lists\n`capture.stream` only when the host can stream now.\n\n`capture.stream.start` takes the same target fields as `capture.still`, plus:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `maxWidth` | int | no | Largest coded width, 320 to 3840; defaults to `1920` |\n| `fps` | int | no | Frame rate cap, 5 to 60; defaults to `30` |\n| `codec` | string | no | `h264`, the only codec required |\n\nThe result is `{streamId, codec, width, height, frame:{x,y,w,h}, display}`.\n`display` is `{index, name}` for a display target and null otherwise. Hosts\nthat report capture activity also include `activity`, as described below.\n\n- `capture.stream.ack {streamId, seq}` acknowledges every frame up to `seq`.\n  Send one per frame received. Hosts limit the number of frames in flight.\n  With frames outstanding and no ack for 3 s, the host ends the stream.\n  An ack for an old `seq` changes\n  nothing and still answers `{ok:true}`, so viewers use it to check that a\n  quiet stream is alive. Hosts that report capture activity include the current\n  `activity` in successful ACK responses.\n- `capture.stream.keyframe {streamId}` makes the next frame an IDR.\n- `capture.stream.stop {streamId}` stops the stream without an event.\n- Streams belong to their connection. Only that connection can ack, request a\n  keyframe or stop them, and they all stop when it closes.\n- When the host ends a stream itself, it sends\n  `{event:\"capture.stream.ended\", data:{streamId, reason}}` to the owning\n  connection, whatever it subscribed to. Reasons are `ack-timeout`,\n  `window-closed`, `encoder-failed: …` and, on a Mac, `capture-stopped: …`.\n\n**Host activity and variable frame delivery**\n\nCurrent macOS source builds suppress unchanged pictures before H.264 encoding.\nThe host compares the visible pixels of complete capture frames and accepts\nScreenCaptureKit's explicit unchanged-frame status as idle evidence. After at\nleast one second without a picture change, with unchanged-frame evidence, the\nhost reports `idle`. A pixel or capture-geometry change reports `active`\nimmediately and resumes frame delivery, subject to the ACK limit.\n\nThe capture rate remains at the requested `fps` ceiling so the host can observe\nnew changes promptly. The encoded video rate can fall to zero while the picture\nis unchanged. The viewer holds the last picture. A requested recovery keyframe\ncan still send the same picture without changing the activity state.\n\nSupporting hosts include this optional object in start and successful ACK\nresponses:\n\n```json\n{\"activity\":{\"state\":\"idle\",\"source\":\"host\",\"activeFPS\":30,\"idleFPS\":0,\"revision\":2}}\n```\n\n`state` is `active`, `idle` or `unknown`. `unknown` means the host does not have\ncurrent evidence of picture activity, including before the first frame or when\ncapture is blank or suspended. `activeFPS` is the configured frame rate ceiling;\n`idleFPS` is the intended unchanged-picture rate, currently zero. Neither field\nis a measurement of received frames.\n\n`revision` is a nonnegative integer, starting at zero for each stream and\nincreasing on each state change. Viewers ignore activity reports with an older\nrevision so a delayed ACK cannot overwrite a newer activity event.\n\nWhen the state changes, the host sends\n`{event:\"capture.stream.activity\", data:{streamId, activity}}` only to the\nconnection that owns the stream. The gateway forwards this event for streams\nowned by that viewer. No event subscription is required.\n\nOlder hosts and the current Linux host omit `activity`. Viewers must not infer\n`idle` from missing frames, low received FPS or an omitted activity object.\nThose hosts do not promise the macOS unchanged-picture suppression behavior.\n\n**Binary frames**\n\nEach binary message is one access unit, big-endian:\n\n| Offset | Size | Field |\n|--------|------|-------|\n| 0 | 4 | `\"LVF1\"` |\n| 4 | u32 | `streamId` |\n| 8 | u32 | `seq`, per stream, from 1 |\n| 12 | u8 | flags: bit 0 keyframe, bit 1 parameter sets follow |\n| 13 | u8 | codec: 1 H.264, 2 HEVC |\n| 14 | u16 | reserved, 0 |\n| 16 | u64 | `pts`, microseconds on the host clock |\n| 24 | u32 | width of the coded picture |\n| 28 | u32 | height |\n| 32 | 4 × f32 | `frame` x, y, w, h: the host-space rect the picture shows |\n| 48 | | if bit 1: u16 count, then u16 length + bytes for each parameter set (SPS, PPS; VPS first for HEVC) |\n| | | the access unit: NAL units, each with a u32 length prefix (AVCC, not Annex B) |\n\nEvery keyframe carries its parameter sets. There are no B-frames. The codec\nlives in `apps/mac/Sources/Core/Hosts/RemoteVideoFrame.swift` and\n`packages/host-linux/src/stream.ts`.\n\n#### `capture.screenshotDisplay`\n\nCapture one complete composited display as a PNG artifact without focusing a\nwindow or changing visible overlays. The image includes the menu bar, pointer\nby default, and Lattices-owned surfaces already visible on that display, which\nmakes it suitable for snapshots of transient UI such as Hyper+G. When no\ndisplay is supplied, Lattices captures the display under the pointer.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `displayIndex` | int | no | Zero-based target from `spaces.list`; defaults to the display under the pointer |\n| `display` | int | no | Alias for `displayIndex` |\n| `cursor` | bool | no | Include the pointer; defaults to `true` |\n| `clipboard` | bool | no | Copy PNG image data to the system clipboard; defaults to `true` for raw RPC calls |\n| `title` | string | no | Optional run title |\n| `runId` | string | no | Existing run to append to |\n| `source` | string | no | Calling surface label |\n| `filename` | string | no | Optional artifact filename |\n\n```js\nawait daemonCall('capture.screenshotDisplay', {\n  displayIndex: 0,\n  clipboard: true,\n  filename: 'hyper-g-layout.png',\n  source: 'agent'\n})\n```\n\nThe CLI uses the positional index as `display`, always writes the run artifact,\nand copies the PNG only when `--clipboard` is present. `--delay` is a CLI-side\nwait measured in seconds; it is not an RPC parameter.\n\n```bash\nlats capture display 0 --clipboard --delay 3 --filename hyper-g-layout.png\n```\n\n#### `capture.screenshotRegion`\n\nCapture an explicit screen rectangle as a PNG artifact. If no rectangle is\nprovided, the endpoint resolves `wid`/`session`/`app`/frontmost target and\ncaptures that window frame.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `x`, `y` | double | no | Region origin in screen coordinates |\n| `width`, `height` | double | no | Region size |\n| `w`, `h` | double | no | Size aliases |\n| `wid`, `session`, `app`, `title` | target | no | Target window fallback |\n| `runId`, `source`, `filename` | mixed | no | Standard run/artifact fields |\n\n```js\nawait daemonCall('capture.screenshotRegion', {\n  x: 120,\n  y: 160,\n  width: 640,\n  height: 420,\n  filename: 'composer-region.png'\n})\n```\n\n#### `capture.zoomArtifact`\n\nCrop and zoom an existing image artifact into a new PNG artifact on the same\nrun. Coordinates are image pixels; ratio fields are relative to the source\nimage.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `runId` | string | no | Run containing the artifact |\n| `artifactId` | string | no | Source artifact id |\n| `path` | string | no | Source image path fallback |\n| `x`, `y`, `width`, `height` | double | no | Crop rectangle in image pixels |\n| `xRatio`, `yRatio`, `widthRatio`, `heightRatio` | double | no | Crop rectangle as image ratios |\n| `scale` | double | no | Zoom scale, default `2`, max `8` |\n| `filename`, `source` | string | no | Output artifact metadata |\n\n```js\nawait daemonCall('capture.zoomArtifact', {\n  runId: 'run_...',\n  artifactId: 'art_...',\n  xRatio: 0.35,\n  yRatio: 0.7,\n  widthRatio: 0.3,\n  heightRatio: 0.2,\n  scale: 3\n})\n```\n\n#### `vision.analyzeWindow` / `vision.analyzeArtifact`\n\nRun explicit local OCR-based visual analysis. These endpoints do not call a\ncloud vision model; they use the same on-device Vision OCR path as Lattices OCR\nand return `fullText`, text blocks, a short answer, and optional `verified`\nwhen `contains` or `notContains` is supplied.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `instruction` | string | yes | Question or analysis instruction |\n| `wid`, `session`, `app`, `title` | target | no | Window target for `vision.analyzeWindow` |\n| `runId`, `artifactId`, `path` | mixed | no | Artifact target for `vision.analyzeArtifact` |\n| `contains`, `notContains` | string | no | Optional text expectation |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('vision.analyzeWindow', {\n  app: 'Safari',\n  instruction: 'Read the visible page heading',\n  contains: 'Documentation'\n})\n```\n\nCLI:\n\n```bash\nlats capture window\nlats capture display 0 --clipboard --delay 3\nlats runs\nlats runs run_20260617-120000_a1b2c3\nlats terminals\nlats terminals --refresh\nlats computer prepare --text \"# hello\" --treatment stage\nlats call computer.windowState '{\"app\":\"Finder\",\"maxDepth\":4}'\nlats call computer.pressKey '{\"app\":\"Finder\",\"key\":\"escape\",\"treatment\":\"stage\"}'\nlats call computer.hotkey '{\"app\":\"Xcode\",\"shortcut\":\"command+b\",\"treatment\":\"stage\"}'\nlats computer focus-window --wid 7258 --treatment present\nlats computer cursor --style marker --shape chevron --angle-deg -8 --label typing\nlats computer launch-app Scout\nlats computer scout --treatment present\nlats computer scout \"Draft memo text\" --execute\nlats computer type-window --app Scout --text \"Draft memo text\" --x-ratio .5 --y-ratio .86 --execute\nlats computer click --app Scout --x-ratio .5 --y-ratio .86 --execute\nlats cua click --app Scout --x-ratio .74 --y-ratio .95 --transport ax --ax-label Send --execute\nlats call computer.scroll '{\"app\":\"Safari\",\"direction\":\"down\",\"amount\":420,\"treatment\":\"stage\"}'\nlats call computer.drag '{\"app\":\"Finder\",\"fromXRatio\":0.2,\"fromYRatio\":0.2,\"toXRatio\":0.7,\"toYRatio\":0.7,\"treatment\":\"stage\"}'\nlats call computer.doubleClick '{\"app\":\"Finder\",\"xRatio\":0.5,\"yRatio\":0.5,\"treatment\":\"stage\"}'\nlats call computer.rightClick '{\"app\":\"Finder\",\"xRatio\":0.5,\"yRatio\":0.5,\"treatment\":\"stage\"}'\nlats call computer.verify '{\"app\":\"Safari\",\"mode\":\"ocr\",\"contains\":\"Docs\"}'\nlats call capture.screenshotRegion '{\"app\":\"Safari\",\"filename\":\"safari-region.png\"}'\nlats call vision.analyzeWindow '{\"app\":\"Safari\",\"instruction\":\"Read visible text\"}'\nlats call browser.getText '{\"app\":\"Safari\"}'\nlats call browser.queryDom '{\"app\":\"Safari\",\"selector\":\"h1\",\"allowAutomation\":true}'\nlats computer type-text --text \"# hello from lattices\"\nlats computer demo-terminal --dry-run\n```\n\n---\n\n#### Computer Action Treatments\n\nComputer-use endpoints accept a `treatment` field that controls how intrusive\nthe action may be:\n\n| Treatment | Behavior |\n|-----------|----------|\n| `observe` | Resolve target and record a run, without focus or input |\n| `stage` | Resolve target and stage intent/artifacts, without focus or input |\n| `present` | Focus or present the target, without input |\n| `execute` | Perform the action after safety checks |\n\nThe legacy `dryRun: true` flag maps to `stage`.\n\n#### Background-Safety Receipts\n\nComputer-use action responses include a `backgroundSafety` receipt plus\ntop-level compatibility fields:\n\n```json\n{\n  \"backgroundSafety\": {\n    \"route\": \"ax.press\",\n    \"lane\": \"same_session\",\n    \"session\": \"parent\",\n    \"background_safe\": true,\n    \"backgroundSafe\": true,\n    \"cursor_moved\": false,\n    \"foreground_changed\": false\n  }\n}\n```\n\nAgents should treat `background_safe: true` as the signal that the call stayed\nout of the user's foreground session. AX, tmux, iTerm session, overlay, staged,\nand observe routes can be background-safe. Pointer input, app activation,\nwindow focus, global keyboard input, and foreground typing report\n`background_safe: false`.\n\n#### `computer.prepare`\n\nResolve and score terminal candidates for a future computer-use action. This is\nthe least intrusive endpoint: by default it observes the target and captures an\nartifact, but it does not focus or type.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Specific terminal window id |\n| `tty` | string | no | Specific terminal TTY |\n| `app` | string | no | Preferred terminal app, such as `iTerm2` |\n| `text` | string | no | Text to stage in the run trace |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `capture` | bool | no | Capture target screenshot artifact. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.prepare', {\n  text: '# review before typing',\n  treatment: 'stage'\n})\n```\n\n#### `computer.windowState`\n\nInspect a target window's Accessibility tree and return snapshot-local element\nids (`e1`, `e2`, ...), a flat `elements` list, and a compact `treeMarkdown`\nview. `mode: \"ax\"` avoids Screen Recording. Use `mode: \"both\"` or\n`capture: true` when you also want a screenshot artifact linked to a run. The\nendpoint is classified as a write because those capture modes create run\nartifacts.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `mode` | string | no | `ax` (default), `both`, or `screenshot` |\n| `capture` | bool | no | Capture a screenshot artifact. Defaults true for `both`/`screenshot`, false for `ax` |\n| `maxDepth` | int | no | Maximum AX tree depth. Defaults to `8`, max `14` |\n| `maxElements` | int | no | Maximum elements returned. Defaults to `250`, max `1000` |\n| `timeoutMs` | int | no | Traversal timeout. Defaults to `1200`, max `5000` |\n| `source` | string | no | Calling surface label when capture creates a run |\n\n```js\nawait daemonCall('computer.windowState', {\n  app: 'Finder',\n  maxDepth: 4,\n  maxElements: 120\n})\n\nawait daemonCall('computer.windowState', {\n  wid: 7258,\n  mode: 'both',\n  source: 'agent'\n})\n```\n\n#### `computer.elementAction`\n\nStage or execute an Accessibility action against an element returned by a recent\n`computer.windowState` call. Snapshot element ids are intentionally local to the\ndaemon process and expire from the in-memory cache after a short window.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `snapshotId` | string | yes | Snapshot id returned by `computer.windowState` |\n| `elementId` | string | yes | Snapshot-local element id, such as `e4` |\n| `action` | string | no | `press` (default), `showMenu`, or `focus` |\n| `treatment` | string | no | `stage`, `present`, or `execute`. Defaults to `stage` |\n| `backgroundSafe` / `noFocus` | bool | no | With `execute`, perform AX actions without focusing the app when the requested action can run in the background |\n| `dryRun` | bool | no | Stage without performing the action |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nconst state = await daemonCall('computer.windowState', {\n  app: 'Calculator',\n  maxDepth: 6\n})\n\nawait daemonCall('computer.elementAction', {\n  snapshotId: state.snapshotId,\n  elementId: 'e7',\n  action: 'press',\n  treatment: 'execute',\n  backgroundSafe: true\n})\n```\n\n#### `computer.typeElement` / `computer.setValue`\n\nStage or execute text/value insertion against an element returned by a recent\n`computer.windowState` call. `computer.typeElement` accepts `text`;\n`computer.setValue` accepts `value` and otherwise uses the same behavior. In\n`stage`, Lattices records the intended value without changing the app.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `snapshotId` | string | yes | Snapshot id returned by `computer.windowState` |\n| `elementId` | string | yes | Snapshot-local element id, such as `e4` |\n| `text` | string | yes for `typeElement` | Text to set or append |\n| `value` | string | yes for `setValue` | Value to set or append |\n| `append` | bool | no | Append to current `AXValue` instead of replacing it |\n| `typeIntervalMs` | double | no | Per-character interval for typewriter-style AXValue updates |\n| `treatment` | string | no | `stage`, `present`, or `execute`. Defaults to `stage` |\n| `backgroundSafe` / `noFocus` | bool | no | With `execute`, set `AXValue` without focusing the app or AX element |\n| `dryRun` | bool | no | Stage without setting `AXValue` |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nconst state = await daemonCall('computer.windowState', {\n  app: 'Notes',\n  maxDepth: 8\n})\n\nawait daemonCall('computer.typeElement', {\n  snapshotId: state.snapshotId,\n  elementId: 'e12',\n  text: 'Draft note',\n  treatment: 'execute',\n  backgroundSafe: true\n})\n```\n\n#### `computer.pressKey` / `computer.hotkey`\n\nStage or execute keyboard input. `computer.pressKey` sends one key, such as\n`escape`, `enter`, `tab`, an arrow key, or a single character. `computer.hotkey`\nsends a shortcut from either `shortcut: \"command+shift+p\"` or `key` plus\n`modifiers`. Both default to `stage`; `execute` requires an explicit `wid`,\n`app`, or `session` target unless `allowGlobal: true` is passed.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `key` | string | yes for `pressKey` | Key name or single character |\n| `shortcut` | string | yes for `hotkey` without `key` | Shortcut shorthand, such as `command+shift+p` |\n| `modifiers` | array/string | no | `command`, `option`, `control`, `shift` |\n| `count` | int | no | Repeat count. Defaults to `1`, max `20` |\n| `delayMs` | double | no | Delay between repeats. Defaults to `80` |\n| `allowGlobal` | bool | no | Permit execute without target by posting to the focused system target |\n| `treatment` | string | no | `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without posting keyboard input |\n| `capture` | bool | no | Capture before/after artifacts when targeting a window |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.pressKey', {\n  app: 'Finder',\n  key: 'escape',\n  treatment: 'stage'\n})\n\nawait daemonCall('computer.hotkey', {\n  app: 'Xcode',\n  shortcut: 'command+b',\n  treatment: 'execute'\n})\n```\n\n#### `computer.focusWindow`\n\nResolve a target window, optionally capture it, focus it, and verify the focused\nwindow id. Use `treatment: 'observe'` or `stage` to plan without presenting.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for `app` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without focusing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.focusWindow', {\n  app: 'iTerm2',\n  treatment: 'present'\n})\n```\n\n#### `computer.showCursor`\n\nResolve the current cursor location and show a visible cursor appearance. This\nis the cursor equivalent of a typing action: it records a run, cursor target,\nappearance parameters, and trace events. Use `observe` or `stage` to plan\nwithout showing anything.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `x` | double | no | Screen x coordinate. Defaults to current cursor |\n| `y` | double | no | Screen y coordinate. Defaults to current cursor |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `style` | string | no | `spotlight`, `pulse`, or `marker` |\n| `appearance` | string | no | Alias for `style` |\n| `shape` | string | no | Marker shape: `chevron`, `facet`, `shard`, `wedge`, `prism`, or `notch` |\n| `angleDeg` | double | no | Marker rotation in degrees. Positive rotates visually clockwise; default is `-8` for marker |\n| `size` | string | no | Marker size: `small`, `regular`, or `large`. Defaults to Settings |\n| `color` | string | no | `blue`, `green`, `amber`, `pink`, `red`, or `white` |\n| `durationMs` | int | no | Appearance duration in milliseconds |\n| `label` | string | no | Optional marker label |\n| `dryRun` | bool | no | Stage without showing |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.showCursor', {\n  style: 'marker',\n  shape: 'chevron',\n  angleDeg: -8,\n  size: 'regular',\n  color: 'white',\n  treatment: 'present'\n})\n```\n\n#### `computer.launchApp`\n\nLaunch or focus a normal macOS app and record the result as a run. Use\n`dryRun: true` or `treatment: 'stage'` to plan without launching.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `app` | string | yes | App name, such as `Scout`, `Slack`, or `Notes` |\n| `bundleId` | string | no | Bundle identifier fallback for precise launch |\n| `path` | string | no | Explicit `.app` bundle path |\n| `title` | string | no | Optional title substring for app window selection |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without launching |\n| `capture` | bool | no | Capture the launched app window. Defaults to `true` outside dry-run |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.launchApp', {\n  app: 'Scout',\n  treatment: 'present'\n})\n```\n\n#### `computer.typeWindowText`\n\nFocus a normal app window and insert text. If click coordinates are provided,\nLattices clicks that target before typing. Coordinates can be absolute screen\npoints (`x`, `y`) or ratios inside the target window (`xRatio`, `yRatio`).\nFor window ratios, `0,0` is the top-left of the window and `1,1` is the\nbottom-right.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `text` | string | yes | Text to insert |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `send` | bool | no | Alias for `enter` in chat-style demos |\n| `x`, `y` | double | no | Absolute click point before typing |\n| `xRatio`, `yRatio` | double | no | Window-relative click point before typing |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.typeWindowText', {\n  app: 'Scout',\n  text: 'Draft memo text',\n  xRatio: 0.5,\n  yRatio: 0.86,\n  treatment: 'execute'\n})\n```\n\n#### `computer.click` / `computer.doubleClick` / `computer.rightClick`\n\nStage or execute a click target. `stage` records the target without clicking.\nIn `execute`, `transport: \"auto\"` prefers `AXPress` on the resolved accessibility\nbutton/control before falling back to a pointer click. Use `transport: \"ax\"` or\n`noFocus: true` when the action must not focus the app or move the hardware\npointer. `computer.doubleClick` forces two pointer clicks; `computer.rightClick`\nforces the right button. When a window target is provided, ratios are relative to\nthat window.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `x`, `y` | double | no | Absolute click point |\n| `xRatio`, `yRatio` | double | no | Window-relative click point |\n| `button` | string | no | `left` or `right`; defaults to `left` |\n| `count` | int | no | Pointer click count. Defaults to `1`, max `8` |\n| `delayMs` | double | no | Delay between repeated pointer clicks |\n| `transport` | string | no | `auto`, `ax`, or `pointer`; defaults to `auto` |\n| `axLabel` | string | no | Optional AX text/title hint, such as `Send` |\n| `noFocus` | bool | no | Require no-focus AX execution; disable pointer fallback |\n| `treatment` | string | no | `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without clicking |\n| `capture` | bool | no | Capture before/after artifacts when targeting a window |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.click', {\n  app: 'Scout',\n  xRatio: 0.5,\n  yRatio: 0.86,\n  treatment: 'execute'\n})\n\nawait daemonCall('computer.click', {\n  app: 'Scout',\n  xRatio: 0.74,\n  yRatio: 0.95,\n  transport: 'ax',\n  axLabel: 'Send',\n  noFocus: true,\n  treatment: 'execute'\n})\n```\n\n#### `computer.scroll` / `computer.drag`\n\nStage or execute richer pointer input while keeping the same run/capture model.\n`computer.scroll` posts scroll wheel events at an absolute or window-relative\npoint. `computer.drag` interpolates a pointer drag from a start point to an end\npoint. Both default to `stage`.\n\n**Scroll params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid`, `session`, `app`, `title` | target | no | Target window/session/app |\n| `x`, `y` | double | no | Absolute pointer point before scrolling |\n| `xRatio`, `yRatio` | double | no | Window-relative pointer point |\n| `direction` | string | no | `down` (default), `up`, `left`, or `right` |\n| `amount` | double | no | Scroll amount when using `direction`; default `420` |\n| `deltaX`, `deltaY` | double | no | Explicit scroll wheel deltas |\n| `count` | int | no | Number of scroll events; default `1`, max `30` |\n| `delayMs` | double | no | Delay between repeated events |\n| `treatment`, `dryRun`, `capture`, `source` | mixed | no | Standard computer action fields |\n\n**Drag params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid`, `session`, `app`, `title` | target | no | Target window/session/app |\n| `fromX`, `fromY` | double | no | Absolute drag start point |\n| `toX`, `toY` | double | no | Absolute drag end point |\n| `fromXRatio`, `fromYRatio` | double | no | Window-relative drag start point |\n| `toXRatio`, `toYRatio` | double | no | Window-relative drag end point |\n| `x`, `y`, `xRatio`, `yRatio` | double | no | Aliases for the drag end point |\n| `button` | string | no | `left` or `right`; defaults to `left` |\n| `durationMs` | double | no | Drag duration; default `360` |\n| `steps` | int | no | Interpolated drag event count; default `18` |\n| `treatment`, `dryRun`, `capture`, `source` | mixed | no | Standard computer action fields |\n\n```js\nawait daemonCall('computer.scroll', {\n  app: 'Safari',\n  direction: 'down',\n  amount: 420,\n  treatment: 'execute'\n})\n\nawait daemonCall('computer.drag', {\n  app: 'Finder',\n  fromXRatio: 0.2,\n  fromYRatio: 0.2,\n  toXRatio: 0.7,\n  toYRatio: 0.7,\n  treatment: 'stage'\n})\n```\n\n#### `computer.verify`\n\nVerify a post-action outcome using OCR, AX snapshot text/value, or artifact hash\ncomparison. OCR verification captures/analyzes the target when needed. AX\nverification can use a `snapshotId`/`elementId` from `computer.windowState`.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `mode` | string | no | `ocr` (default), `ax`, or `artifactChanged` |\n| `wid`, `session`, `app`, `title` | target | no | Window target |\n| `snapshotId`, `elementId` | string | no | AX snapshot/element target |\n| `runId`, `artifactId`, `path` | mixed | no | Image artifact target for OCR verification |\n| `contains`, `expected` | string | no | Text that should be present |\n| `notContains` | string | no | Text that should be absent |\n| `beforeArtifactId`, `afterArtifactId` | string | no | Artifact ids for `artifactChanged` |\n| `beforePath`, `afterPath` | string | no | Path fallback for `artifactChanged` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.verify', {\n  app: 'Safari',\n  mode: 'ocr',\n  contains: 'Lattices'\n})\n\nconst state = await daemonCall('computer.windowState', { app: 'Notes' })\nawait daemonCall('computer.verify', {\n  mode: 'ax',\n  snapshotId: state.snapshotId,\n  elementId: 'e4',\n  contains: 'Draft'\n})\n```\n\n#### `browser.getText` / `browser.queryDom` / `browser.executeJavascript`\n\nBrowser primitives are intentionally gated. `browser.getText` reads visible text\nthrough Accessibility and does not enable JavaScript automation. DOM querying\nand arbitrary JavaScript use AppleScript browser automation and require\n`allowAutomation: true`; JavaScript mutation also requires\n`treatment: \"execute\"`.\n\nSupported browser app names: `Safari`, `Google Chrome`, `Chrome`, `Brave\nBrowser`, `Microsoft Edge`, and `Arc`.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid`, `app`, `title` | target | no | Browser target |\n| `selector` | string | yes for `queryDom` | CSS selector |\n| `limit` | int | no | Max DOM nodes, default `20`, max `200` |\n| `script` | string | yes for `executeJavascript` | JavaScript source |\n| `allowAutomation` | bool | yes for DOM/execute | Explicit browser automation consent |\n| `treatment` | string | no | `stage` (default) or `execute` for JavaScript |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('browser.getText', { app: 'Safari' })\n\nawait daemonCall('browser.queryDom', {\n  app: 'Safari',\n  selector: 'h1, main a',\n  allowAutomation: true\n})\n\nawait daemonCall('browser.executeJavascript', {\n  app: 'Safari',\n  script: 'document.title',\n  treatment: 'execute',\n  allowAutomation: true\n})\n```\n\n#### `computer.demoScout`\n\nWarm up a Scout memo/demo recording run. In `present` mode it launches or\nfocuses Scout and records a run without typing. In `execute` mode it can click\nthe likely composer area, type a draft, and optionally press Enter when\n`enter` or `send` is true. Dry-run/stage mode does not capture by default, so it\nworks before Screen Recording permission is granted.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `app` | string | no | Scout app name override. Defaults to `Scout` |\n| `title` | string | no | Optional title substring for the Scout window |\n| `text` | string | no | Draft text to type in `execute` mode |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `send` | bool | no | Alias for `enter` |\n| `click` | bool | no | Click the likely composer area before typing |\n| `xRatio`, `yRatio` | double | no | Composer click point; defaults to `0.5`, `0.86` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without launching or typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` outside dry-run |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.demoScout', { dryRun: true })\n\nawait daemonCall('computer.demoScout', {\n  treatment: 'present',\n  capture: false\n})\n\nawait daemonCall('computer.demoScout', {\n  text: 'Draft memo text',\n  treatment: 'execute',\n  send: false\n})\n```\n\n#### `computer.typeText`\n\nResolve a terminal target and insert text after safety checks. Lattices prefers\nthe least intrusive available transport: tmux pane input when a tmux pane is\nknown, then target-pid key events/pasteboard insertion when window focus is\nrequired. Enter is never pressed unless `enter: true` is provided.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Specific terminal window id |\n| `tty` | string | no | Specific terminal TTY |\n| `app` | string | no | Preferred terminal app, such as `iTerm2` |\n| `text` | string | yes | Text to insert |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `transport` | string | no | `auto`, `tmux`, or `pasteboard`. Defaults to `auto` |\n| `dryRun` | bool | no | Stage without typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.typeText', {\n  text: '# hello from lattices',\n  transport: 'auto',\n  enter: false\n})\n```\n\n#### `computer.demoTerminal`\n\nCompatibility endpoint for the original terminal demo. It follows the same\ntreatment, safety, capture, and transport rules as `computer.typeText`, but\nprovides a default text payload when `text` is omitted.\n\nRun a bounded computer-use sequence against a terminal window:\n\n1. synthesize and score terminal candidates\n2. choose a safe shell-like terminal unless `wid` or `tty` is supplied\n3. capture a `before` screenshot artifact\n4. focus the terminal window\n5. insert text without pressing Enter by default\n6. capture an `after` screenshot artifact\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Specific terminal window id |\n| `tty` | string | no | Specific terminal TTY |\n| `app` | string | no | Preferred terminal app, such as `iTerm2` |\n| `text` | string | no | Text to insert |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `transport` | string | no | `auto`, `tmux`, or `pasteboard`. Defaults to `auto` |\n| `dryRun` | bool | no | Plan and capture without typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.demoTerminal', { dryRun: true })\n\nawait daemonCall('computer.demoTerminal', {\n  app: 'iTerm2',\n  text: '# hello from lattices',\n  enter: false\n})\n```\n\n---\n\n## Overlay UI\n\nThe macOS app exposes a shared desktop overlay canvas for lightweight\nagent-visible UI. Use `overlay.publish` for transient passive visuals,\nand `overlay.actor.*` for persistent, movable actor surfaces.\n\nPersistent actors are useful for representing agents or processes on the\ndesktop. Each actor has a stable `id`, can be moved independently through the\nAPI, dragged by the user, hidden/restored with **Hyper+B**, and closed with\nright-click. Click event callbacks and action surfaces are planned follow-on\ncapabilities.\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `overlay.publish` | write | Publish a transient toast, label, highlight, or pet layer |\n| `overlay.clear` | write | Clear one overlay layer by id, or clear an owner namespace |\n| `overlay.actor.publish` | write | Create or update a persistent generative overlay actor |\n| `overlay.actor.moveTo` | write | Move an actor with app-owned easing |\n| `overlay.actor.hud` | write | Attach, update, or clear a hover web HUD for an actor |\n| `overlay.actor.visibility` | write | Show, hide, toggle, or inspect the sticky actor layer |\n\n#### `overlay.publish`\n\nPublish a transient layer on the screen overlay canvas.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `kind` | string | yes | `toast`, `label`, `highlight`, or `pet` |\n| `id` | string | no | Stable layer id; generated if omitted |\n| `text` | string | no | Toast/label text or pet message fallback |\n| `detail` | string | no | Secondary toast/label text |\n| `message` | string | no | Pet message |\n| `petId` | string | no | Bundled pet id from `apps/mac/Resources/Pets` |\n| `state` | string | no | Pet animation state |\n| `placement` | string | no | `top`, `bottom`, `center`, `cursor`, or `point` |\n| `x`, `y` | double | no | Screen-local point for `point` placement |\n| `w`, `h` | double | no | Highlight size |\n| `ttlMs` | int | no | Time to live in milliseconds |\n| `dismissible` | bool | no | Whether click-away dismissal removes the layer; defaults `true` |\n\nExample:\n\n```js\nawait daemonCall('overlay.publish', {\n  kind: 'highlight',\n  x: 160,\n  y: 120,\n  w: 480,\n  h: 260,\n  text: 'Needs review',\n  style: 'warning',\n  ttlMs: 3000\n})\n```\n\n#### `overlay.actor.publish`\n\nCreate or update a generative overlay actor. Actors default to persistent:\nomit `ttlMs` or pass `0`, and `dismissible` defaults to `false`.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `id` | string | no | Stable actor id; generated if omitted |\n| `renderer` | string | no | `sprite` is currently supported |\n| `asset` | string | no | Bundled sprite asset id, such as `scout-ranger` |\n| `state` | string | no | Actor state or animation name |\n| `name` | string | no | Actor display name |\n| `message` | string | no | Attached message text |\n| `targetApp` | string | no | App name to activate when the actor is clicked |\n| `targetBundleId` | string | no | Bundle identifier to activate when the actor is clicked |\n| `targetAppPath` | string | no | `.app` bundle path to open when the actor is clicked |\n| `scale` | double | no | Actor scale multiplier |\n| `labelHidden` | bool | no | Hide the actor label/message |\n| `closeOnActivate` | bool | no | Remove the actor after activating its target app |\n| `hudUrl` | string | no | URL to render in a transparent hover HUD web view |\n| `hudHTML` | string | no | Inline HTML to render in a transparent hover HUD web view |\n| `hudWidth` | double | no | Hover HUD width |\n| `hudHeight` | double | no | Hover HUD height |\n| `hudReadAccess` | string | no | Local folder a file-backed HUD may read |\n| `placement` | string | no | `top`, `bottom`, `center`, `cursor`, or `point` |\n| `x`, `y` | double | no | Screen-local point for `point` placement |\n| `ttlMs` | int | no | Time to live; `0` means persistent |\n| `dismissible` | bool | no | Whether click-away dismissal removes the actor |\n\nBundled sprite assets:\n\n| Asset | Notes |\n|-------|-------|\n| `assistant-spark` | Animated states include `idle`, `run_right`, `run_left`, `waving`, `jumping`, `failed`, `waiting`, `running`, and `review` |\n| `scout-ranger` | Bundled sprite asset with default frame fallback |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.publish', {\n  id: 'agent-scout',\n  renderer: 'sprite',\n  asset: 'scout-ranger',\n  state: 'waiting',\n  name: 'Scout',\n  message: 'Waiting for feedback',\n  placement: 'point',\n  x: 640,\n  y: 320,\n  ttlMs: 0\n})\n```\n\n#### `overlay.actor.moveTo`\n\nMove an actor with app-owned animation. The app interpolates position and\nswitches directional sprite states while moving.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `id` | string | yes | Actor id |\n| `x`, `y` | double | yes | Target screen-local point |\n| `durationMs` | int | no | Animation duration |\n| `easing` | string | no | `linear`, `easeInOut`, or `spring` |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.moveTo', {\n  id: 'agent-scout',\n  x: 820,\n  y: 280,\n  durationMs: 800,\n  easing: 'spring'\n})\n```\n\n#### `overlay.actor.hud`\n\nAttach a transparent, blurred hover HUD to an actor. The HUD is backed by a\nnative `WKWebView`, so apps can point it at a local static HTML dashboard or,\nin development, a local URL.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `id` | string | yes | Actor id |\n| `hudUrl` | string | no | URL or file path to render |\n| `hudHTML` | string | no | Inline HTML to render instead of a URL |\n| `hudTitle` | string | no | HUD title metadata |\n| `hudWidth` | double | no | HUD width |\n| `hudHeight` | double | no | HUD height |\n| `hudReadAccess` | string | no | Local folder a file-backed HUD may read |\n| `clear` | bool | no | Remove the actor HUD |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.hud', {\n  id: 'switch-talkie',\n  hudUrl: '/Users/you/dev/talkie/.lattices/hud/index.html',\n  hudReadAccess: '/Users/you/Library/Application Support/Talkie/HUD',\n  hudWidth: 380,\n  hudHeight: 260\n})\n```\n\n#### `overlay.actor.visibility`\n\nShow, hide, toggle, or inspect the persistent actor layer without destroying\nthe actors. This is the daemon equivalent of the app's **Hyper+B** shortcut.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `action` | string | no | `show`, `hide`, `toggle`, or `status` |\n| `visible` | bool | no | Set layer visibility directly |\n| `hidden` | bool | no | Set layer hidden state directly |\n| `feedback` | bool | no | Show a short desktop feedback toast |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.visibility', { action: 'toggle' })\n```\n\n### Static HUD Manifests\n\nApps and projects can expose a local hover dashboard without running a web\nserver by publishing a static bundle at:\n\n```txt\n.lattices/hud/\n  manifest.json\n  index.html\n  assets/\n```\n\nMinimal manifest:\n\n```json\n{\n  \"version\": 1,\n  \"id\": \"talkie\",\n  \"name\": \"Talkie\",\n  \"bundleId\": \"com.usabletalkie.Talkie\",\n  \"icon\": \"./assets/icon.png\",\n  \"entry\": \"./index.html\",\n  \"readAccess\": \"~/Library/Application Support/Talkie/HUD\",\n  \"surface\": { \"width\": 380, \"height\": 260 },\n  \"actor\": {\n    \"labelHidden\": true,\n    \"click\": \"activateApp\"\n  },\n  \"sources\": [\n    {\n      \"path\": \"~/Library/Application Support/Talkie/HUD/activity.jsonl\",\n      \"format\": \"jsonl\",\n      \"schema\": \"talkie.activity.v1\",\n      \"presentation\": \"timeline\"\n    }\n  ]\n}\n```\n\nThe CLI resolves this manifest into `overlay.actor.publish` with a file-backed\nHUD URL. The macOS app loads `entry` through `WKWebView.loadFileURL`, allowing\nread access to the HUD folder by default, or to the manifest's `readAccess`\nfolder when one is declared.\n\n`sources` is descriptive metadata for app-owned state, logs, or event streams.\nLattices does not append to those logs. The app writes them in its normal\nruntime location, and the custom HUD renderer decides how to present them.\n\nUseful commands:\n\n```bash\nlats hud register .lattices/hud/manifest.json --publish\nlats hud publish talkie\nlats hud sync\nlats hud discover ~/dev --register\n```\n\nFor packaged apps, keep the renderer files in the app bundle and point mutable\nsources at an app-owned folder such as `~/Library/Application Support/...`.\n\n---\n\n## System\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `deck.manifest` | read | Shared companion deck manifest |\n| `deck.snapshot` | read | Current companion deck runtime snapshot |\n| `deck.perform` | write | Perform a companion deck action |\n| `solo.status` | read | Check whether Focus Mode is active |\n| `stage.status` | read | Check whether Stage is active |\n| `stage.enter` | write | Enter Stage for the windows on this display |\n| `stage.exit` | write | Exit Stage and restore the windows |\n| `stage.toggle` | write | Toggle Stage |\n| `stage.cycle` | write | Move Stage to another window in the set |\n| `solo.enter` | write | Spotlight the frontmost window |\n| `solo.exit` | write | Exit Focus Mode and restore the window |\n| `solo.toggle` | write | Toggle Focus Mode for the frontmost window |\n| `daemon.status` | read | Health check and stats |\n| `api.schema` | read | Full API schema for self-discovery |\n| `diagnostics.list` | read | Recent diagnostic entries |\n\n#### `deck.manifest`\n\nReturn the shared `DeckKit` manifest exposed by the macOS app. This is\nthe contract a future Lattices companion can consume to discover pages,\ncapabilities, and security mode.\n\n**Params**: none\n\n#### `deck.snapshot`\n\nReturn the current `DeckKit` runtime snapshot for the macOS host.\n\n**Params**: none\n\n**Returns**: a `DeckRuntimeSnapshot` object containing:\n\n- `voice`\n- `desktop`\n- `switcher`\n- `history`\n- `questions`\n\n#### `deck.perform`\n\nPerform a `DeckKit` action against the running macOS host and return a\n`DeckActionResult`.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `pageID` | string | no | Deck page ID |\n| `actionID` | string | yes | Deck action identifier |\n| `payload` | object | no | Deck action payload |\n\nExample:\n\n```json\n{\n  \"id\": \"1\",\n  \"method\": \"deck.perform\",\n  \"params\": {\n    \"pageID\": \"layout\",\n    \"actionID\": \"layout.optimize\",\n    \"payload\": {\n      \"strategy\": \"balanced\",\n      \"region\": \"right\"\n    }\n  }\n}\n```\n\n#### `solo.status`, `solo.enter`, `solo.exit`, `solo.toggle`\n\nControl the same reversible Focus Mode exposed through the **Hyper+Z** chord\nand command palette. `solo.enter` targets the currently frontmost window;\n`solo.exit` restores the exact frame captured on entry.\n\n```bash\nlats call solo.enter\nlats call solo.status\nlats call solo.exit\n```\n\nEach method returns the resulting `active` state. Mutating methods also return\n`ok`; `solo.enter` returns `ok: false` when there is no eligible frontmost\nwindow.\n\n#### `stage.status`, `stage.enter`, `stage.exit`, `stage.toggle`, `stage.cycle`\n\nStage is on or off. The windows on the display you are using decide the grid:\none window takes the stage, and more windows fill the grid around it. A window\nthat appears while Stage is on joins the grid. The window you are in grows to\n40% of the display width and comes forward. The others stay in their cells,\nslightly smaller. Clicking another window in the set, or `stage.cycle`, moves\nthe stage. Escape and `stage.exit` put every window back where it was when it\njoined. Hyper+X toggles it. Control-Option-Tab moves through the set while it\nis on.\n\n```bash\nlats call stage.enter\nlats call stage.cycle '{\"by\":-1}'\nlats call stage.exit\n```\n\n`stage.enter` turns Stage on whatever the window count is. `stage.cycle` takes `by` (default `1`).\n\n#### `daemon.status`\n\nHealth check and basic stats.\n\n**Params**: none\n\n**Returns**:\n\n```json\n{\n  \"uptime\": 3600.5,\n  \"clientCount\": 2,\n  \"version\": \"1.0.0\",\n  \"windowCount\": 12,\n  \"tmuxSessionCount\": 3,\n  \"frontmostWid\": 1234,\n  \"permissions\": {\n    \"accessibility\": true,\n    \"screenRecording\": true\n  }\n}\n```\n\n#### `api.schema`\n\nReturn the full API schema including version, models, and method definitions.\nUseful for agent self-discovery.\n\n**Params**: none\n\nCLI shortcut:\n\n```bash\nlats call api.schema\n```\n\n#### `diagnostics.list`\n\nReturn recent diagnostic log entries from the daemon.\n\n**Params**:\n\n| Field   | Type   | Required | Description                    |\n|---------|--------|----------|--------------------------------|\n| `limit` | number | no       | Max entries to return (default 50) |\n\n---\n\n## Mouse & Input\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `mouse.find` | read | Show a sonar pulse at the current cursor |\n| `mouse.summon` | write | Move the cursor to a point or screen center |\n| `mouse.shortcuts.get` | read | Return the live mouse shortcut config |\n| `mouse.shortcuts.reload` | write | Reload `~/.lattices/mouse-shortcuts.json` without restarting |\n| `mouse.shortcuts.set` | write | Replace the full mouse shortcut config and activate it |\n| `mouse.shortcuts.upsert` | write | Create or replace one mouse shortcut rule and activate it |\n| `mouse.shortcuts.remove` | write | Remove one mouse shortcut rule and activate the new config |\n| `mouse.shortcuts.restoreDefaults` | write | Restore default mouse shortcuts |\n\nMouse shortcut rules are data. Prefer `shortcut.send` for hotkeys an agent can\ndefine or change directly; do not add a named action unless the behavior cannot\nbe expressed as data.\n\nCreate or replace a gesture that sends Hyper+D:\n\n```js\nawait daemonCall('mouse.shortcuts.upsert', {\n  rule: {\n    id: 'middle-up-voice',\n    enabled: true,\n    device: 'any',\n    trigger: { button: 'middle', kind: 'drag', direction: 'up' },\n    action: {\n      type: 'shortcut.send',\n      shortcut: {\n        key: 'd',\n        keyCode: 2,\n        modifiers: ['control', 'option', 'shift', 'command']\n      }\n    }\n  }\n})\n```\n\nIf an agent edits `~/.lattices/mouse-shortcuts.json` itself, refresh the running\napp explicitly:\n\n```js\nawait daemonCall('mouse.shortcuts.reload')\n```\n\nAll write methods persist the config, checkpoint the previous version in\n`~/.lattices/mouse-shortcuts.history/`, and update the active event-tap snapshot\nimmediately. No app restart is required.\n\nSupported action types:\n\n| Type | Purpose |\n|------|---------|\n| `shortcut.send` | Send a data-defined key or keyCode with modifiers |\n| `app.activate` | Activate an app by name |\n| `space.previous` | Switch to the previous macOS Space |\n| `space.next` | Switch to the next macOS Space |\n| `screenmap.toggle` | Open the Screen Map overview |\n| `dictation.start` | Legacy alias that presses the configured Voice Command hotkey |\n\n---\n\n## Windows & Spaces\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `desktop.snapshot` | read | One-call current desktop: frontmost, layer, displays, windows, sessions, permissions |\n| `windows.list` | read | All visible windows |\n| `windows.get` | read | Single window by ID |\n| `windows.preview` | read | Cached PNG preview for a window |\n| `windows.search` | read | Search windows by query |\n| `spaces.list` | read | macOS display spaces |\n| `windows.pick` | read | Ask the user to choose one visible window in a read-only Hyperspace survey |\n| `windows.place` | write | Place a window or session using a typed placement spec |\n| `windows.focus` | write | Focus a window / switch Spaces |\n| `windows.move` | write | Move a window to another display, placement slot, or Space |\n| `spaces.optimize` | write | Optimize a set of windows using an explicit scope and strategy |\n| `layout.distribute` | write | Compatibility wrapper for visible-window balancing |\n\n#### `desktop.snapshot`\n\nOne call for “what is in front of the user?” Structured state — not a drawing.\n`lats map` is the ASCII render of this same current-space view (it now\nreads `desktop.snapshot` when the app is new enough).\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `includeOffscreen` | boolean | no | Include off-screen windows. Default `false`. |\n\n**Returns**:\n\n```json\n{\n  \"frontmost\": { \"wid\": 1234, \"app\": \"iTerm2\", \"title\": \"…\", \"pid\": 1 },\n  \"activeLayer\": { \"id\": \"web\", \"label\": \"Web\", \"index\": 0, \"slot\": 1, \"active\": true, \"layout\": null, \"entries\": [] },\n  \"displays\": [ { \"displayIndex\": 0, \"name\": \"Built-in\", \"frame\": {}, \"visibleFrame\": {}, \"currentSpaceId\": 1, \"spaces\": [] } ],\n  \"currentSpaceId\": 1,\n  \"windows\": [\n    {\n      \"wid\": 1234,\n      \"app\": \"iTerm2\",\n      \"title\": \"…\",\n      \"pid\": 5678,\n      \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n      \"spaceIds\": [1],\n      \"isOnScreen\": true,\n      \"zIndex\": 0,\n      \"focused\": true,\n      \"latticesSession\": \"lattices-a1b2c3\"\n    }\n  ],\n  \"sessions\": [ { \"name\": \"lattices-a1b2c3\", \"attached\": true, \"windowCount\": 1 } ],\n  \"permissions\": { \"accessibility\": true, \"screenRecording\": true }\n}\n```\n\nWindow frames are top-left global, same as `windows.list`. Does not recapture\nwindows or run OCR.\n\n```bash\nlats call desktop.snapshot\nlats map\n```\n\n#### `windows.list`\n\nList all visible windows tracked by the desktop model.\n\n**Params**: none\n\n**Returns**: array of window objects:\n\n```json\n[\n  {\n    \"wid\": 1234,\n    \"app\": \"Terminal\",\n    \"pid\": 5678,\n    \"title\": \"[lattices:myapp-a1b2c3] zsh\",\n    \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"spaceIds\": [1],\n    \"isOnScreen\": true,\n    \"latticesSession\": \"myapp-a1b2c3\"\n  }\n]\n```\n\nThe `latticesSession` field is present only on windows that belong to\na lattices session (matched via the `[lattices:name]` title tag).\n\n#### `windows.get`\n\nGet a single window by its CGWindowID.\n\n**Params**:\n\n| Field | Type   | Required | Description       |\n|-------|--------|----------|-------------------|\n| `wid` | number | yes      | CGWindowID        |\n\n**Returns**: a single window object (same shape as `windows.list` items).\n**Errors**: `Not found` if the window ID doesn't exist.\n\n#### `windows.search`\n\nSearch windows by text query across title, app name, session tags, and OCR content. Returns results with `matchSource` indicating how the match was found, and `ocrSnippet` for OCR matches.\n\n**Params**:\n\n| Field   | Type   | Required | Description                    |\n|---------|--------|----------|--------------------------------|\n| `query` | string | yes      | Search query (matches title, app, session, OCR text) |\n| `ocr`   | boolean| no       | Include OCR text in search (default true) |\n| `limit` | number | no       | Max results (default 50)       |\n\n**Returns**: array of window objects with additional search fields:\n\n```json\n[\n  {\n    \"wid\": 265,\n    \"app\": \"iTerm2\",\n    \"title\": \"✳ Claude Code\",\n    \"matchSource\": \"ocr\",\n    \"ocrSnippet\": \"…~/dev/vox StatusBarIconFolder…\",\n    \"frame\": { \"x\": 688, \"y\": 3, \"w\": 1720, \"h\": 720 },\n    \"isOnScreen\": true\n  }\n]\n```\n\n`matchSource` values: `\"title\"`, `\"app\"`, `\"session\"`, `\"ocr\"`.\n\n**CLI usage**:\n\n```bash\n# Basic search (uses windows.search)\nlats search vox\n\n# Deep search — adds terminal tab/process inspection for ranking\nlats search vox --deep\n\n# Same as --deep (all search sources)\n# A TTY matched only at app level (see terminals.list) adds 1 point to each\n# window of its terminal app, with the source \"terminal-app\". `lats place`\n# does not act on a result that has only that source.\nlats search vox --all\n\n# Pipeable output\nlats search vox --wid\nlats search vox --json\n\n# Search + focus + tile in one step\nlats place vox right\n```\n\n#### `windows.preview`\n\nReturn a cached PNG preview for a tracked window. When the cache is cold,\n`load` starts an asynchronous warmup and the caller can poll until `ok` is true.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | number | yes | CGWindowID from `windows.list` |\n| `load` | boolean | no | Warm the cache when missing; defaults to `true` |\n\nThe response includes `cached` and `loading`. A ready preview also includes\n`mimeType: \"image/png\"`, base64 image data, and pixel dimensions.\n\n#### `spaces.list`\n\nList macOS display spaces (virtual desktops).\n\nDisplay `frame` and `visibleFrame` use the same top-left global coordinate\nsystem as window frames returned by `windows.list`.\nSee [Workspace Map](/docs/workspace-map) for the complete coordinate contract,\nterminal/JSON projection, and visible-surface lifecycle guidance.\n\n**Params**: none\n\n**Returns**: array of display objects:\n\n```json\n[\n  {\n    \"displayIndex\": 0,\n    \"displayId\": \"main\",\n    \"name\": \"Built-in Liquid Retina XDR Display\",\n    \"frame\": { \"x\": 0, \"y\": 0, \"w\": 1728, \"h\": 1117 },\n    \"visibleFrame\": { \"x\": 0, \"y\": 38, \"w\": 1728, \"h\": 1079 },\n    \"currentSpaceId\": 1,\n    \"spaces\": [\n      { \"id\": 1, \"index\": 1, \"name\": \"Desktop 1\", \"display\": 0, \"isCurrent\": true },\n      { \"id\": 2, \"index\": 2, \"name\": \"Desktop 2\", \"display\": 0, \"isCurrent\": false }\n    ]\n  }\n]\n```\n\n#### `windows.place`\n\nCanonical window placement mutation. Use this when an agent needs a\nsingle, typed placement contract across voice, CLI, and daemon clients.\n\n**Params**:\n\n| Field       | Type            | Required | Description                              |\n|-------------|-----------------|----------|------------------------------------------|\n| `wid`       | number          | no       | Target window ID                          |\n| `session`   | string          | no       | Target lattices session                   |\n| `app`       | string          | no       | Target app name                           |\n| `title`     | string          | no       | Optional title substring for app matching |\n| `display`   | number          | no       | Target display index                      |\n| `placement` | string \\| object | yes     | Placement shorthand or typed object       |\n| `dryRun`    | bool            | no       | Plan and verify inputs without moving     |\n\nTarget resolution priority is `wid` → `session` → `app/title` → frontmost window.\n\n**Placement strings**: `left`, `right`, `top`, `bottom`, `top-left`, `top-right`,\n`bottom-left`, `bottom-right`, `left-third`, `center-third`, `right-third`,\n`top-third`, `middle-third`, `bottom-third`, `left-quarter`, `right-quarter`,\n`top-quarter`, `bottom-quarter`, `maximize`, `center`, `grid:CxR:C,R`, or\ncompact `CxR:C,R`. The canonical `grid:` form is 0-indexed; the compact form is\n1-indexed for command entry.\n\n**Typed placement examples**:\n\n```json\n{ \"kind\": \"tile\", \"value\": \"top-right\" }\n{ \"kind\": \"grid\", \"columns\": 3, \"rows\": 2, \"column\": 2, \"row\": 0 }\n{ \"kind\": \"fractions\", \"x\": 0.5, \"y\": 0, \"w\": 0.5, \"h\": 1 }\n```\n\n**Returns**: execution receipt including resolved target, placement, and trace.\n\n#### `windows.pick`\n\nOpen Hyperspace in a read-only selection mode and wait for the user to choose\none visible window. Letter hints, Tab/Return, and clicking a survey tile select;\nEscape cancels. Placement, layer editing, and normal motion commands are disabled\nfor the duration of the pick.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `client` | string | no | Calling client label |\n| `requestId` | string | no | Caller correlation id |\n| `prompt` | string | no | Prompt shown in the selection HUD |\n| `mode` | string | no | `single-window` (default and currently supported mode) |\n| `currentWid` | number | no | Visible window to preselect |\n| `timeoutMs` | number | no | Wait time, clamped to 5–300 seconds; default 120 seconds |\n\nReturns `status: \"selected\"` with a window, or `cancelled`, `timeout`, or\n`unavailable` without one.\n\n#### `windows.focus`\n\nFocus a window — bring it to front and switch Spaces if needed.\n\n**Params** (one of):\n\n| Field     | Type   | Required | Description                     |\n|-----------|--------|----------|---------------------------------|\n| `wid`     | number | no       | CGWindowID (any window)         |\n| `session` | string | no       | Session name (lats windows)  |\n\nProvide either `wid` or `session`. If `wid` is given, it takes priority.\n\n#### `windows.move`\n\nMove a specific window to a display and desktop in one verified operation.\nWithout a placement, the window keeps its normalized position and size on the\ntarget display. A Space ID identifies one desktop on one display; Desktop 2\non a physical monitor differs from Desktop 2 on a virtual display.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | number | no | Target CGWindowID (preferred) |\n| `session` | string | no | Session name (legacy target) |\n| `display` | number | no | Target display index from `spaces.list` |\n| `spaceId` | number | no | Absolute target Space ID from `spaces.list` |\n| `placement` | string \\| object | no | Placement slot on the target display |\n| `position` | string | no | Alias for `placement` |\n| `dryRun` | bool | no | Validate and plan without moving the window or changing the view |\n\nProvide `wid` or `session`, plus at least one of `display`, `spaceId`, or\n`placement`. There is no frontmost-window fallback.\n\n- `spaceId` selects its owning display when `display` is omitted. If both are\n  supplied, the Space must belong to that display. A mismatch is an error.\n- `display` alone selects that display's current desktop. Display indexes\n  resolve through stable display UUIDs.\n- `placement` can accompany either destination. Without a placement, a\n  cross-display move maps the source frame onto the target visible frame and\n  clamps it to fit. A same-display desktop move preserves the frame.\n- Unknown displays, Spaces, and placements are errors. A window must belong\n  to one ordinary desktop; full-screen and multi-Space windows are rejected\n  when their relocation cannot be verified.\n\nFor an inactive source desktop, Lattices first carries the window to the\nsource display's current desktop. It then moves the window to the target\ndisplay and carries it to the requested desktop. Mission Control can appear\nduring these steps. Keep the pointer free until the operation finishes.\n\n**Returns**: an execution receipt with `status`\n(`ok`/`planned`/`blocked`/`failed`), before/target/after frames in `mutations`,\n`sourceDisplay`, `display`, `spaceId`, `fromSpaceIds`, and `afterSpaceIds`.\n`verified` requires `verifiedFrame`, `verifiedDisplay`, and `verifiedSpace`\nto succeed. An AX call or a desktop view switch alone cannot report success.\nA placement receipt retains `action.type: \"window.place\"` and\n`compatibilityMethod: \"window.move\"`.\n\nOn partial failure, Lattices attempts to restore the original display,\ndesktop, and frame. The receipt reports `failureReason`, `rollback`, and the\nobserved final state. Successful moves support `actions.undo`, including the\noriginal desktop. Allow up to 60 seconds for verification and recovery.\n\n```js\n// Move to the current desktop on display 0, preserving relative geometry.\nawait daemonCall('window.move', { wid: 4182, display: 0 }, 60000)\n\n// Choose the exact physical display and desktop in one operation.\nawait daemonCall('window.move', { wid: 4182, display: 0, spaceId: 3 }, 60000)\n\n// Preview a move and placement without changing the window or desktop view.\nawait daemonCall('window.move', {\n  wid: 4182, display: 0, spaceId: 3, placement: 'left', dryRun: true\n})\n```\n\n#### `spaces.optimize`\n\nCanonical space-balancing mutation. Use this when the goal is to make\nthe current workspace coherent rather than placing one specific window.\n\n**Params**:\n\n| Field       | Type     | Required | Description                                         |\n|-------------|----------|----------|-----------------------------------------------------|\n| `scope`     | string   | no       | `visible`, `active-app`, `app`, or `selection`      |\n| `strategy`  | string   | no       | `balanced` or `mosaic`                              |\n| `app`       | string   | no       | App name for `app` scope                            |\n| `title`     | string   | no       | Optional title substring for app matching           |\n| `windowIds` | number[] | no       | Explicit window IDs for `selection` scope           |\n\nIf `windowIds` is provided, scope is inferred as `selection`.\nIf `app` is provided and `scope` is omitted, scope is inferred as `app`.\n\n**Returns**: execution receipt including resolved scope, strategy,\naffected window IDs, and trace.\n\n#### `layout.distribute`\n\nCompatibility wrapper for `spaces.optimize` with `scope=visible` and\n`strategy=balanced`.\n\n**Params**: none\n\n---\n\n## Sessions\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `tmux.list` | read | Lattices tmux sessions, or every session including orphans |\n| `sessions.launch` | write | Launch a project session |\n| `sessions.kill` | write | Kill a session |\n| `sessions.detach` | write | Detach clients from a session |\n| `sessions.sync` | write | Reconcile session to config |\n| `sessions.restart` | write | Restart a pane's process |\n\nAll session methods require tmux to be installed.\n\n#### `tmux.list`\n\nList tmux sessions that belong to lattices.\n\n**Params**:\n\n| Field            | Type    | Required | Description |\n|------------------|---------|----------|-------------|\n| `includeOrphans` | boolean | no       | Return every tmux session, including ones lattices does not track |\n\n**Returns**: array of session objects:\n\n```json\n[\n  {\n    \"name\": \"myapp-a1b2c3\",\n    \"windowCount\": 1,\n    \"attached\": true,\n    \"panes\": [\n      {\n        \"id\": \"%0\",\n        \"windowIndex\": 0,\n        \"windowName\": \"main\",\n        \"title\": \"claude\",\n        \"currentCommand\": \"claude\",\n        \"pid\": 9876,\n        \"isActive\": true\n      }\n    ]\n  }\n]\n```\n\nWith `includeOrphans: true`, returns every tmux session instead, including\norphans (sessions not tracked by lattices):\n\n```json\n{\n  \"all\": [ ... ],\n  \"orphans\": [ ... ]\n}\n```\n\nBoth arrays contain session objects (same shape as `tmux.list`).\n\n#### `sessions.launch`\n\nLaunch a new tmux session for a project. If a session already exists,\nit will be reattached. The project must be in the scanned project list —\ncall `projects.list` to check, or `projects.scan` to refresh.\n\n**Params**:\n\n| Field  | Type   | Required | Description                      |\n|--------|--------|----------|----------------------------------|\n| `path` | string | yes      | Absolute path to project directory |\n\n**Returns**: a receipt, not a queued ok:\n\n```json\n{\n  \"ok\": true,\n  \"session\": \"myapp-a1b2c3\",\n  \"alreadyRunning\": false,\n  \"wid\": 1234,\n  \"verified\": true\n}\n```\n\n`ok` / `verified` are false if no tagged window and no tmux session appeared\nwithin ~4s. **Errors**: `Not found` if the path isn't in the scanned project\nlist.\n\n#### `sessions.kill`\n\nKill a tmux session by name.\n\n**Params**:\n\n| Field  | Type   | Required | Description         |\n|--------|--------|----------|---------------------|\n| `name` | string | yes      | Session name        |\n\n**Returns**: `{ \"ok\": true, \"session\": \"…\", \"verified\": true }` when the\nsession is gone. `ok` / `verified` are false if it is still listed.\n\n#### `sessions.detach`\n\nDetach all clients from a session (keeps it running).\n\n**Params**:\n\n| Field  | Type   | Required | Description         |\n|--------|--------|----------|---------------------|\n| `name` | string | yes      | Session name        |\n\n#### `sessions.sync`\n\nReconcile a running session to match its declared `.lattices.json` config.\nRecreates missing panes, re-applies layout, restores labels, re-runs\ncommands in idle panes.\n\n**Params**:\n\n| Field  | Type   | Required | Description                      |\n|--------|--------|----------|----------------------------------|\n| `path` | string | yes      | Absolute path to project directory |\n\n**Errors**: `Not found` if the path isn't in the project list.\n\n#### `sessions.restart`\n\nRestart a specific pane's process within a session.\n\n**Params**:\n\n| Field  | Type   | Required | Description                      |\n|--------|--------|----------|----------------------------------|\n| `path` | string | yes      | Absolute path to project directory |\n| `pane` | string | no       | Pane name to restart (defaults to first pane) |\n\n---\n\n## Projects & Layers\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `projects.list` | read | Discovered projects |\n| `projects.scan` | write | Re-scan project directory |\n| `layers.list` | read | Workspace layers, active index, and what switches put away |\n| `layers.members` | read | Each layer's entries and the windows they hold, on any desktop |\n| `layers.exit` | write | Exit layer focus and restore parked windows |\n| `layers.reveal` | write | Compatibility alias for `layers.exit` |\n| `layers.create` | write | Save windows as a new layer |\n| `layers.assign` | write | Add windows to a layer |\n| `layers.unassign` | write | Take windows out of a layer |\n| `layers.rename` | write | Rename a layer |\n| `layers.delete` | write | Delete a layer |\n| `layers.activate` | write | Switch to a workspace layer; `focus`, `tile` or `launch` |\n| `layers.switch` | write | Same as `layers.activate` |\n| `groups.launch` | write | Launch a tab group |\n| `groups.kill` | write | Kill a tab group |\n| `tabs.list` | read | List ephemeral cross-app tab stacks |\n| `tabs.stack` | write | Create a stack from selected or explicit windows |\n| `tabs.add` | write | Add windows to a live stack |\n| `tabs.select` | write | Focus a member tab |\n| `tabs.layout` | write | Toggle or set tabs/grid layout |\n| `tabs.unstack` | write | Ungroup without closing windows |\n\n#### `projects.list`\n\nList all discovered projects.\n\n**Params**: none\n\n**Returns**: array of project objects:\n\n```json\n[\n  {\n    \"path\": \"/Users/you/dev/myapp\",\n    \"name\": \"myapp\",\n    \"sessionName\": \"myapp-a1b2c3\",\n    \"isRunning\": true,\n    \"hasConfig\": true,\n    \"paneCount\": 2,\n    \"paneNames\": [\"claude\", \"server\"],\n    \"devCommand\": \"pnpm dev\",\n    \"packageManager\": \"pnpm\"\n  }\n]\n```\n\n`devCommand` and `packageManager` are present only when detected.\n\n#### `projects.scan`\n\nTrigger a re-scan of the project directory. Useful after cloning a new\nrepo or adding a `.lattices.json` config.\n\n**Params**: none\n\n#### `layers.list`\n\nList configured workspace layers and whether layer focus is active.\n\n**Params**: none\n\n**Returns**:\n\n```json\n{\n  \"layers\": [\n    { \"id\": \"web\", \"label\": \"Web\", \"index\": 0, \"projectCount\": 2 },\n    { \"id\": \"mobile\", \"label\": \"Mobile\", \"index\": 1, \"projectCount\": 2 }\n  ],\n  \"active\": 0,\n  \"focusActive\": true,\n  \"rememberedIndex\": 0,\n  \"stage\": { \"parked\": [], \"hidden\": [] }\n}\n```\n\n`active` is `null` after exiting layers. `rememberedIndex` retains the last\nselected layer index; it does not mean that layer focus is active. `stage`\nlists windows parked and apps hidden by Lattices. Returns an empty `layers`\narray if no workspace config is loaded.\n\n#### `layers.exit`\n\nExit layer focus and restore windows that Lattices parked or hid. Saved\nlayer definitions and window desktop assignments remain intact. Apps the\nuser hid stay hidden. Select a layer explicitly to enter layer focus again.\n\n**Params**: none\n\n**Returns**:\n\n```json\n{\n  \"ok\": true,\n  \"focusActive\": false,\n  \"active\": null,\n  \"unparked\": 3,\n  \"stillParked\": 0,\n  \"rescued\": 0,\n  \"unhidden\": [\"Slack\"],\n  \"stillHidden\": [],\n  \"recoveryPending\": false,\n  \"persistenceWarning\": null,\n  \"stage\": { \"parked\": [], \"hidden\": [] }\n}\n```\n\n`unparked` counts restored windows. `rescued` counts untracked windows\nrecovered from the parking corner. `stillParked` and `stillHidden` report\nrestoration that remains pending; `ok` confirms exit, not complete recovery.\n\n`recoveryPending` remains true while window restoration or saving the recovery\nstate is pending. `persistenceWarning` is a string when saving fails, or `null`\nwhen saving succeeds. Restoration can finish before the state is saved, so\n`recoveryPending` can be true when `stillParked` is zero and `stillHidden` is\nempty. Lattices retains the recovery state and retries saving it.\n\nA window on an inactive desktop can remain parked until that desktop is\nvisible. Lattices retains its saved position and retries restoration when\nyou visit the desktop. The exit action does not switch desktops or gather\nwindows onto the current desktop. Check `recoveryPending` and `stage` for\noutstanding recovery.\n\n#### `layers.reveal`\n\nCompatibility alias for `layers.exit`, with the same result. The former\nShow All action also exits layer focus. Lattices centres untracked windows\nfound in the parking corner on the main screen and counts them in `rescued`.\n\n#### `layers.members`\n\nEach layer's entries and the windows each one holds, on every desktop,\nfrom the same resolution a layer switch uses. A window belongs to one\nlayer. `presence` says where it is: `showing` (on a desktop a display is\nshowing), `elsewhere` (another desktop, or full screen), `parked` (in the\ncorner a switch parks windows in) or `hidden` (its app is hidden).\n`missing` says why an entry holds nothing: `No window` when its app runs,\n`Not open` when it doesn't. Moves nothing.\n\n**Params**:\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| `layer` | string | no | Layer id or label (default: every layer) |\n| `index` | int | no | Layer index |\n\n**Returns**:\n\n```json\n{\n  \"active\": 0,\n  \"layers\": [\n    {\n      \"id\": \"web\", \"label\": \"Web\", \"index\": 0, \"slot\": 1, \"active\": true, \"layout\": \"auto\",\n      \"entries\": [\n        {\n          \"index\": 0, \"name\": \"Ghostty\", \"rule\": \"App contains Ghostty · Name: web\", \"pattern\": \"web\",\n          \"missing\": null,\n          \"windows\": [\n            { \"wid\": 4812, \"app\": \"Ghostty\", \"title\": \"mini: web · server\", \"presence\": \"parked\", \"where\": \"Parked\" }\n          ]\n        },\n        { \"index\": 1, \"name\": \"Figma\", \"rule\": \"App contains Figma\", \"pattern\": null, \"missing\": \"Not open\", \"windows\": [] }\n      ]\n    }\n  ]\n}\n```\n\n`desktop.snapshot` reports the active layer in the same shape as\n`activeLayer`.\n\n#### Editing layers\n\n`layers.create`, `layers.assign`, `layers.unassign`, `layers.rename` and\n`layers.delete` write the `layers` in `~/.lattices/workspace.json`, the\nsame ones ⌘⌥ switches. The first write each launch copies the file to\n`workspace.json.bak`. A window is saved as an entry of its app and its\ntitle; while the window lives it stays in the layer when its title changes.\n\nEach takes the layer as `layer` (id or label) or `index`; `assign` and\n`unassign` default to the active layer, and take `wid` or `windowIds`.\n\n| Method | Params | Returns |\n|--------|--------|---------|\n| `layers.create` | `name`; `windowIds`, or `windows: [{wid, tile?}]`, or `visible` (the default: every window on screen) | `{index, id, label, count}` |\n| `layers.assign` | `wid` / `windowIds` | `{index, added}` |\n| `layers.unassign` | `wid` / `windowIds` | `{index, removed, held}`: `held` lists windows an entry matching other windows too (a bare app, a project) still holds |\n| `layers.rename` | `name` | `ok` |\n| `layers.delete` | | `ok` |\n\n```js\nawait daemonCall('layers.create', { name: 'review', windows: [{ wid: 1234, tile: 'left' }, { wid: 5678 }] })\nawait daemonCall('layers.assign', { layer: 'review', wid: 4321 })\nawait daemonCall('layers.unassign', { layer: 'review', wid: 4321 })\n```\n\n#### `layers.activate`\n\nSwitch to a workspace layer. With no `mode` it switches as ⌘⌥ does.\n\n**Params**:\n\n| Field   | Type   | Required | Description                                 |\n|---------|--------|----------|---------------------------------------------|\n| `index` | number | no       | Layer index (0-based)                       |\n| `name`  | string | no       | Layer ID or label                           |\n| `mode`  | string | no       | `focus` (default), `tile`, or `launch`      |\n\nProvide either `index` or `name`.\n\n**Modes**:\n\n- `focus` — put away what the layer doesn't use, raise its windows and\n  apply its `layout`: the ⌘⌥ switch\n- `tile` — focus, then move entries with a `tile` to their place\n  (`retile` is accepted too)\n- `launch` — start the projects that aren't running, then tile\n\n**Returns**: execution receipt including resolved layer, mode, and trace.\n\n#### `layers.switch`\n\nThe same as `layers.activate`, `focus` unless a `mode` is given. It\nposts a `layer.switched` event.\n\n#### `groups.launch`\n\nLaunch a tab group session.\n\n**Params**:\n\n| Field | Type   | Required | Description      |\n|-------|--------|----------|------------------|\n| `id`  | string | yes      | Group ID         |\n\n**Errors**: `Not found` if the group ID doesn't match any configured group.\n\n#### `groups.kill`\n\nKill a tab group session.\n\n**Params**:\n\n| Field | Type   | Required | Description      |\n|-------|--------|----------|------------------|\n| `id`  | string | yes      | Group ID         |\n\n#### Live tab stacks\n\nLive stacks operate on windows that are already open and last for the current\nLattices run. If `windowIds` is omitted, create/add uses the latest multi-window\nselection from Hyperspace.\n\n```bash\nlats call tabs.stack '{\"name\":\"Research\",\"placement\":\"top-left\"}'\nlats call tabs.layout '{\"mode\":\"grid\"}'\nlats call tabs.select '{\"index\":1}'\nlats call tabs.layout '{\"mode\":\"tabs\"}'\nlats call tabs.unstack '{}'\n```\n\n`tabs.stack` needs at least two live windows. You may instead pass\n`{\"windowIds\":[123,456]}`. The optional `id` on add/select/layout/delete defaults\nto the active group. Layout mode is `tabs`, `grid`, or `toggle`; selecting uses a\nzero-based `index` or `windowId`.\n\n---\n\n## Processes & Terminals\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `processes.list` | read | Running developer processes |\n| `processes.tree` | read | Process tree from a PID |\n| `terminals.list` | read | Terminal instances with processes |\n| `terminals.search` | read | Search terminals by criteria |\n\n#### `processes.list`\n\nList running processes relevant to development (editors, servers, build tools).\n\n**Params**:\n\n| Field     | Type   | Required | Description                        |\n|-----------|--------|----------|------------------------------------|\n| `command` | string | no       | Filter by command name substring   |\n\n**Returns**: array of process objects:\n\n```json\n[\n  {\n    \"pid\": 1234,\n    \"ppid\": 567,\n    \"command\": \"node\",\n    \"args\": \"server.js\",\n    \"cwd\": \"/Users/you/dev/myapp\",\n    \"tty\": \"/dev/ttys003\",\n    \"tmuxSession\": \"myapp-a1b2c3\",\n    \"tmuxPaneId\": \"%0\"\n  }\n]\n```\n\n#### `processes.tree`\n\nGet the process tree rooted at a given PID.\n\n**Params**:\n\n| Field | Type   | Required | Description   |\n|-------|--------|----------|---------------|\n| `pid` | number | yes      | Root PID      |\n\n**Returns**: array of process objects (same shape as `processes.list`).\n\n#### `terminals.capture`\n\nExact tmux pane text. Not OCR, not a screenshot.\n\nProvide one of `session`, `paneId`, or `tty`. If a session has more than one\npane, also pass `pane` or `paneId`.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `session` | string | one of session / paneId / tty | tmux or lattices session name |\n| `pane` | string | no | Pane title or `%id` |\n| `paneId` | string | no | tmux pane id (`%12`) |\n| `tty` | string | no | `/dev/ttys003` or `ttys003` |\n| `lines` | number | no | Last N lines. Default 80, max 500 |\n| `escape` | boolean | no | Keep ANSI. Default `false` (`tmux capture-pane -p`) |\n\n**Returns**:\n\n```json\n{\n  \"ok\": true,\n  \"session\": \"lattices-a1b2c3\",\n  \"paneId\": \"%3\",\n  \"paneName\": \"claude\",\n  \"tty\": \"/dev/ttys012\",\n  \"text\": \"…last N lines…\",\n  \"lineCount\": 80\n}\n```\n\nAmbiguous targets return an error listing candidates. Missing tmux returns a\nclear error. Never falls back to pixels.\n\n```bash\nlats call terminals.capture '{\"session\":\"lattices-c36f74\",\"lines\":40}'\n```\n\n#### `terminals.list`\n\nList all discovered terminal instances with their processes, tabs, and tmux associations.\n\n**Params**:\n\n| Field     | Type    | Required | Description                          |\n|-----------|---------|----------|--------------------------------------|\n| `refresh` | boolean | no       | Explicitly refresh terminal-tab metadata through terminal app scripting |\n\n**Returns**: array of terminal instance objects:\n\n```json\n[\n  {\n    \"tty\": \"/dev/ttys003\",\n    \"app\": \"Terminal\",\n    \"windowIndex\": 0,\n    \"tabIndex\": 0,\n    \"isActiveTab\": true,\n    \"tabTitle\": \"myapp\",\n    \"processes\": [ ... ],\n    \"shellPid\": 1234,\n    \"cwd\": \"/Users/you/dev/myapp\",\n    \"tmuxSession\": \"myapp-a1b2c3\",\n    \"tmuxPaneId\": \"%0\",\n    \"hasClaude\": true,\n    \"displayName\": \"Terminal — myapp\",\n    \"windowId\": 4211,\n    \"windowAttribution\": \"terminal\"\n  }\n]\n```\n\nTerminal coverage. With `refresh: true`, Lattices reads tab data from these\nterminal apps, and only from apps that are already running:\n\n| Terminal | Tab data | Window match | What the terminal needs |\n|----------|----------|--------------|-------------------------|\n| iTerm2 | AppleScript | Window ID | Automation access |\n| Terminal.app | AppleScript | Window ID | Automation access |\n| Ghostty 1.3+ | AppleScript (tab title, terminal ID, cwd) | Window title, when one window has it | Automation access that you already granted. Lattices does not ask for it. |\n| kitty | `kitty @ ls` | Window ID | `allow_remote_control` and `listen_on` in `kitty.conf` |\n| Warp, Alacritty, others | None | Process tree only | Nothing |\n\nGhostty reports no TTY, so Lattices joins each Ghostty terminal to a TTY by\nits working directory. It skips a terminal when two shells share that\ndirectory. When tab data is not available, Lattices finds the terminal\nprocess that owns the TTY. It picks that process's window when the process\nhas one titled window, or when exactly one window title contains the shell's\ncwd. Otherwise the TTY has no `windowId`. It has `windowAttribution: \"app\"`,\nand `windowCandidates` lists the windows of that process, frontmost first.\n\n`windowAttribution` values: `tag` (the window title has the `[lattices:…]`\ntag), `terminal` (the terminal reported the window ID), `terminal-title`,\n`position`, `cwd-title`, `single-window`, `app`.\n\n#### `terminals.search`\n\nSearch terminal instances by various criteria.\n\n**Params**:\n\n| Field      | Type    | Required | Description                          |\n|------------|---------|----------|--------------------------------------|\n| `command`  | string  | no       | Filter by command name substring     |\n| `cwd`      | string  | no       | Filter by working directory substring |\n| `app`      | string  | no       | Filter by terminal app name          |\n| `session`  | string  | no       | Filter by tmux session name          |\n| `hasClaude`| boolean | no       | Filter to only Claude-running TTYs   |\n\n**Returns**: filtered array of terminal instance objects (same shape as `terminals.list`).\n\n---\n\n## OCR\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `ocr.snapshot` | read | Current OCR results for all visible windows |\n| `ocr.search` | read | Full-text search across OCR history |\n| `ocr.history` | read | OCR timeline for one window, or all windows |\n| `ocr.scan` | write | Trigger an immediate OCR scan |\n\nSee [Screen OCR](/docs/ocr) for configuration, scan schedules, and storage details.\n\n#### `ocr.snapshot`\n\nGet the current in-memory OCR results for all visible windows.\n\n**Params**: none\n\n**Returns**: array of OCR result objects:\n\n```json\n[\n  {\n    \"wid\": 1234,\n    \"app\": \"Terminal\",\n    \"title\": \"zsh\",\n    \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"fullText\": \"~/dev/myapp $ npm run dev\\nready on port 3000\",\n    \"blocks\": [\n      {\n        \"text\": \"~/dev/myapp $ npm run dev\",\n        \"confidence\": 0.95,\n        \"x\": 0.02, \"y\": 0.05, \"w\": 0.6, \"h\": 0.04\n      }\n    ],\n    \"timestamp\": 1709568000.0\n  }\n]\n```\n\n#### `ocr.search`\n\nFull-text search across OCR history using SQLite FTS5.\n\n**Params**:\n\n| Field   | Type    | Required | Description                              |\n|---------|---------|----------|------------------------------------------|\n| `query` | string  | yes      | FTS5 search query                        |\n| `app`   | string  | no       | Filter by application name               |\n| `limit` | number  | no       | Max results (default 50)                 |\n| `live`  | boolean | no       | Search live snapshot instead of history (default false) |\n\n**FTS5 query examples**: `error`, `\"build failed\"`, `error OR warning`, `npm AND dev`, `react*`\n\n#### `ocr.history`\n\nGet the OCR timeline for a specific window, or across every window when\n`wid` is omitted, ordered by most recent first.\n\n**Params**:\n\n| Field   | Type   | Required | Description                |\n|---------|--------|----------|----------------------------|\n| `wid`   | number | no       | CGWindowID; omit for every window |\n| `limit` | number | no       | Max results (default 50)   |\n\n#### `ocr.scan`\n\nTrigger an immediate OCR scan of all visible windows, bypassing the\nperiodic timer. Results available via `ocr.snapshot` once complete;\nan `ocr.scanComplete` event is broadcast to all clients.\n\n**Params**: none\n\n---\n\n## Events\n\nEvents are pushed to all connected WebSocket clients when state changes.\nThey have no `id` field — listen for messages with an `event` field.\n\n| Event | Trigger |\n|-------|---------|\n| `windows.changed` | Desktop window list changes |\n| `tmux.changed` | Sessions created, killed, or modified |\n| `layer.switched` | Active workspace layer changes |\n| `ocr.scanComplete` | OCR scan cycle finishes |\n| `processes.changed` | Developer processes start or stop |\n\nA connection receives every event until it filters them. `events.subscribe`\ntakes `{ \"events\": [\"windows.changed\"] }` (or `[\"*\"]` for all again);\n`events.unsubscribe` removes names, or every event when called without a\nlist. Both act on the connection they arrive on and return its current\nfilter. Linux hosts (LAT-013) push `windows.changed` and `spaces.changed`.\n\n#### `windows.changed`\n\n```json\n{ \"event\": \"windows.changed\", \"data\": { \"windowCount\": 12, \"added\": [1234], \"removed\": [5678] } }\n```\n\n#### `tmux.changed`\n\n```json\n{ \"event\": \"tmux.changed\", \"data\": { \"sessionCount\": 3, \"sessions\": [\"myapp-a1b2c3\"] } }\n```\n\n#### `layer.switched`\n\n```json\n{ \"event\": \"layer.switched\", \"data\": { \"index\": 1, \"name\": \"mobile\" } }\n```\n\n#### `ocr.scanComplete`\n\n```json\n{ \"event\": \"ocr.scanComplete\", \"data\": { \"windowCount\": 12, \"totalBlocks\": 342 } }\n```\n\n#### `processes.changed`\n\n```json\n{ \"event\": \"processes.changed\", \"data\": { \"interestingCount\": 5, \"pids\": [1234, 5678] } }\n```\n\n---\n\n## Agent integration\n\n### CLAUDE.md snippet\n\nAdd this to your project's `CLAUDE.md` so any AI agent working in the\nproject knows how to control the workspace:\n\n```markdown\n## Workspace Control\n\nThis project uses lattices for workspace management. The daemon API\nuses authenticated WebSocket over ~/.lattices/run/lattices.sock.\nUse a CLI or daemonCall client that matches the app version for discovery and authentication.\n\n### Search (find windows)\n- Search by content: `daemonCall('windows.search', { query: 'myproject' })`\n  Returns windows with `matchSource` (\"title\", \"app\", \"session\", \"ocr\") and `ocrSnippet`\n- Search terminals: `daemonCall('terminals.search', {})` — tabs, cwds, processes\n- CLI: `lats search myproject`, `lats search myproject --deep`, or `lats search myproject --all` (same as `--deep`)\n\n### Actions\n- Focus a window: `daemonCall('windows.focus', { wid: 1234 })`\n- Place a window: `daemonCall('windows.place', { session: 'name', placement: 'left' })`\n- Launch a project: `daemonCall('sessions.launch', { path: '/absolute/path' })`\n- Switch layers: `daemonCall('layers.activate', { name: 'web' })`; add `mode: 'launch'` to start what isn't running\n- Optimize the workspace: `daemonCall('spaces.optimize', { scope: 'visible', strategy: 'balanced' })`\n- CLI: `lats place myproject left` (search + focus + tile in one step)\n\n### Import\n\\```js\nimport { daemonCall } from '@lattices/lattices'\n\\```\n```\n\n### Multi-agent orchestration\n\nAn orchestrator agent can set up the full workspace for sub-agents:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Discover what's available\nconst projects = await daemonCall('projects.list')\n\n// Launch the projects we need\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/frontend' })\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/api' })\n\n// Tile them side by side\nconst sessions = await daemonCall('tmux.list')\nconst fe = sessions.find(s => s.name.startsWith('frontend'))\nconst api = sessions.find(s => s.name.startsWith('api'))\n\nawait daemonCall('windows.place', { session: fe.name, placement: 'left' })\nawait daemonCall('windows.place', { session: api.name, placement: 'right' })\n```\n\n### Reactive event pattern\n\nSubscribe to events to react to workspace changes:\n\n```js\nimport WebSocket from 'ws'\nimport { readFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n\nconst runtime = process.env.LATTICES_RUNTIME_DIR || join(homedir(), '.lattices/run')\nconst token = readFileSync(join(runtime, 'lattices.token'), 'utf8').trim()\nconst ws = new WebSocket('ws://localhost', {\n  socketPath: join(runtime, 'lattices.sock'),\n  headers: { Host: 'localhost', 'x-lattices-token': token },\n})\n\nws.on('message', (raw) => {\n  const msg = JSON.parse(raw)\n\n  if (msg.event === 'tmux.changed') {\n    console.log('Sessions:', msg.data.sessions.join(', '))\n  }\n\n  if (msg.event === 'windows.changed') {\n    console.log('Windows:', msg.data.windowCount, 'total')\n  }\n\n  if (msg.event === 'layer.switched') {\n    console.log('Switched to layer', msg.data.index)\n  }\n})\n\n// You can also send RPC calls on the same connection\nws.on('open', () => {\n  ws.send(JSON.stringify({ id: '1', method: 'daemon.status' }))\n})\n```\n\n### Health check before use\n\nAlways verify the daemon is running before making calls:\n\n```js\nimport { isDaemonRunning, daemonCall } from '@lattices/lattices'\n\nif (!(await isDaemonRunning())) {\n  console.error('lats daemon is not running — start it with: lats app')\n  process.exit(1)\n}\n\nconst status = await daemonCall('daemon.status')\nconsole.log(`Daemon up for ${Math.round(status.uptime)}s, tracking ${status.windowCount} windows`)\n```\n\n### Pi extension\n\nPi users can install the `@arach/pi-lattices` package in\n`packages/pi-lattices/` to expose the daemon as typed `lattices_*` tools:\n\n```bash\npi install ./packages/pi-lattices --local\nlats app\n```\n\nThe extension wraps the existing daemon and keeps Lattices' macOS-native\nruntime, run artifacts, action receipts, and computer-use `treatment` semantics.\nIt does not bundle `cua-driver` or enable browser automation. See\n[Pi Lattices Extension](/docs/pi-lattices) for the tool list and smoke checks.\n\n### Machines and visiting cursor\n\nThe Machines page uses these existing endpoints: `visit.status`, `visit.pair`,\n`visit.arm`, `visit.end`, `visit.screens`, and `visit.elsewhere`.\n\n| Method | Parameters | Result |\n| --- | --- | --- |\n| `visit.forget` | `host: string` | `{ok: boolean}`; false if absent. Ends an active visit to that host. |\n| `visit.side` | `host: string`, `side: left\\|right\\|top\\|bottom` | `{ok: true}`; errors for unknown hosts or invalid sides. Occupied sides swap, preserving both keys. |\n| `mouse.home` | none | Ends the current visit and returns the cursor to this Mac. |\n\n`visit.pair` requires host-side approval of the returned code. A new pairing\ncannot replace an occupied side; move or forget its occupant first.\n\n## Machines and displays\n\n`lats @<host> <command...>` routes daemon-backed CLI commands through `callHost`\nusing `~/.lattices/hosts.json` and `LATTICES_HOSTS`. `@local` selects the local\nendpoint. No SSH is used. Commands requiring local files, application lifecycle\nor tmux actions report `local-only`; remote RPC failures never fall back to local\nactions. `lats @host call <method> [params-json]` exposes the host's API directly.\nA host only supports methods present in its installed version.\n\n`lats machines [--json]` merges configured hosts, outbound visit pairings and\nthis machine by name/address. It probes `host.describe` and reports reachability,\nOS, version/commit, display count, pairing and placement. Offline/unknown values\nremain explicit. Pairing inventory falls back to the saved visit host list when\nthe local daemon is unavailable. JSON returns `{machines, pairingError?}`.\n\n| Method | Parameters | Behavior |\n| --- | --- | --- |\n| `host.describe` | none | Mac/Linux identity: `platform`, `hostname`, `version`, `build` (version/commit), `displays`, capabilities and methods. Unknown build fields are null. |\n| `bring` | `display: int|string`, or `undo: true` | Composes `display.gather` for every other display. Undo delegates to `display.restore`. Returns gather or restore results. |\n| `main` | `display: int|string`, `keep?: bool` | Delegates to `visit.main`; starts its 15-second rollback trial. `keep` calls `visit.arrangement.keep` only after success. |\n| `elsewhere` | `display: int|string`, `name?: string` | Alias of `visit.elsewhere` with `on: true`; optionally records the machine on the display. |\n| `here` | `display: int|string` | Alias of `visit.elsewhere` with `on: false`. |\n| `visit.start` | `host: string` | Starts an explicit visit at the midpoint of a paired machine's touching span, without arming edge crossing. Missing pairing, no touching span, overlap or capture failure is an error. End the current visit before starting another. |\n| `home` | none | Alias of `mouse.home`: ends the visit and returns the cursor home. |\n\nCLI forms: `bring dell`, `bring --undo`, `main u32 --keep`,\n`elsewhere u32 archie`, `here u32`, `visit archie`, and `home`.\nDisplay selectors use the `display.gather` resolver: display index from\n`display list`, or a case-insensitive name fragment (first match). The existing\n`visit.main` and `visit.elsewhere` `screen` parameters accept these same selectors;\nCLI `--display` flags and `capture display` also accept names.\n\nThe Machines page, command bar and Machines menu use these same operations.\nThe page shows version/commit and marks a lower numeric release version **Behind**.\nA differing commit without proven version ordering is **Different build**, not a\nclaim about commit ancestry. The Machines page's Open action selects the existing\nstill/live host view. No deployment, pairing, arming or visit occurs on page open.\n",
      "content": "Lattices 0.15.1 and later serve the agent API over authenticated WebSocket on\n`~/.lattices/run/lattices.sock`. The same app hosts voice input on\n`~/.lattices/run/voice.sock` (see [Voice](/lattices/docs/voice)).\n~120 RPC methods and real-time events. Prefer `desktop.snapshot` when you\nneed to know what is in front of the user, and `lats map` when you want\nthat same state drawn as ASCII.\n\n## Quick start\n\n1. Launch the server (it starts with the menu bar app):\n\n```bash\nlats app\n```\n\n2. Check that it's running:\n\n```bash\nlats daemon status\n```\n\n3. Call a method from Node.js:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\nconst windows = await daemonCall('windows.list')\nconsole.log(windows) // [{ wid, app, title, frame, ... }, ...]\n```\n\nFrom another language, use a WebSocket client that supports Unix sockets and\nthe authentication headers below.\n\n### Private connection\n\nInstall the Lattices app and CLI at version 0.15.2 together. Older clients\nthat connect directly to TCP must migrate to the private transport.\n\n- The daemon listens on a private Unix socket, `~/.lattices/run/lattices.sock`,\n  and writes a capability token to `~/.lattices/run/lattices.token`. The\n  directory is mode `0700`; both files are mode `0600`.\n- Directory and token permissions restrict local access to the same user.\n  The macOS server also checks the peer's user ID. The WebSocket upgrade must\n  use `Host: localhost`, carry the token in `x-lattices-token`, and send no\n  `Origin` header.\n- `lats` and `daemonCall()` find the socket and token for you. Code above keeps\n  working. A plain `websocat ws://127.0.0.1:9399` does not: no TCP port is\n  open by default. The macOS app can enable loopback TCP with\n  `LATTICES_ALLOW_LOOPBACK_TCP=1`; that port still requires the token. The\n  Linux host has no TCP compatibility listener.\n- The shared client sends local tokens over private IPC by default. An explicit\n  TCP target, including a loopback address that could be an SSH tunnel, receives\n  no local token unless loopback compatibility is explicitly enabled.\n  The CLI and Pi client require `LATTICES_ALLOW_LOOPBACK_TCP=1`; programmatic\n  `daemonCallTo()` callers can deliberately supply `tokenFile` for a loopback\n  endpoint. Non-loopback targets never receive local tokens.\n- Remote clients reach a host only through the opt-in tailnet gateway, after a\n  person on that host approves the pairing. See\n  [Remote hosts](#remote-hosts).\n\nSee [Private IPC and the gateway](/lattices/docs/concepts#private-ipc-and-the-gateway)\nfor every product's socket.\n\n## Wire protocol\n\nlattices uses a JSON-RPC-style protocol over authenticated WebSocket on the private socket.\n\n### Request\n\n```json\n{\n  \"id\": \"unique-string\",\n  \"method\": \"windows.list\",\n  \"params\": { \"wid\": 1234 }\n}\n```\n\n| Field    | Type    | Required | Description                          |\n|----------|---------|----------|--------------------------------------|\n| `id`     | string  | yes      | Caller-chosen ID, echoed in response |\n| `method` | string  | yes      | Method name (see below)              |\n| `params` | object  | no       | Method-specific parameters           |\n\n### Response\n\n```json\n{\n  \"id\": \"unique-string\",\n  \"result\": [ ... ],\n  \"error\": null\n}\n```\n\n| Field    | Type           | Description                                  |\n|----------|----------------|----------------------------------------------|\n| `id`     | string         | Echoed from request                          |\n| `result` | any \\| null    | Method return value (null on error)          |\n| `error`  | string \\| null | Error message (null on success)              |\n\n### Errors\n\n| Error           | Meaning                              |\n|-----------------|--------------------------------------|\n| Unknown method  | The `method` string is not recognized |\n| Missing parameter | A required param was not provided   |\n| Not found       | The referenced resource doesn't exist |\n\n### Method names and aliases\n\nMethods are named `<domain>.<verb>`, with plural domains: `windows.place`,\n`sessions.launch`, `layers.switch` ([LAT-012](proposals/LAT-012-domain-api.md)).\nThe pre-LAT-012 names still work as aliases, and `api.schema` lists them under\n`aliases` (old name → new name):\n\n| Old | New |\n|-----|-----|\n| `window.focus/move/place/present/resolve` | `windows.focus/move/place/present/resolve` |\n| `window.pick.start` | `windows.pick` |\n| `window.tile` | `windows.place` (`position` becomes `placement`) |\n| `layer.activate`, `layer.switch` | `layers.activate`, `layers.switch` |\n| `space.optimize` | `spaces.optimize` |\n| `session.*` | `sessions.*` |\n| `group.launch/kill` | `groups.launch/kill` |\n| `tabStacks.list/add/select/layout` | `tabs.list/add/select/layout` |\n| `tabStacks.create`, `tabStacks.delete` | `tabs.stack`, `tabs.unstack` |\n| `lattices.search` | `search.query` |\n| `focus.status/enter/exit/toggle` (Focus Mode) | `solo.status/enter/exit/toggle` |\n| `tmux.sessions`, `tmux.inventory` | `tmux.list`, `tmux.list({ includeOrphans: true })` |\n| `ocr.recent` | `ocr.history` without `wid` |\n| `intents.execute` | `intents.run` |\n| `actions.history`, `actions.undo` | `history.list`, `history.undo` |\n\nAction receipts keep their own `type` values (`window.place`, `window.move`);\nthose name the action, not the RPC method. The Node.js `daemonCall()` client\nand LatticesKit retry a new name under its old one when an older daemon\nanswers `Unknown method`.\n\n### Connection lifecycle\n\n- The server starts when the menu bar app launches and stops when it quits.\n- Every connection requires the local token at the WebSocket handshake; see\n  [Private connection](#private-connection).\n- The Node.js `daemonCall()` client opens a fresh connection per call and\n  closes it when the response arrives. For event subscriptions, hold the\n  connection open (see [Reactive event pattern](#agent-integration)).\n- If the server restarts (e.g. after `lats app restart`), existing\n  connections are dropped. Clients should reconnect and treat it as\n  stateless. There is no session resumption.\n\n### Remote hosts\n\nIn 0.15.1, neither the macOS app nor `lattices-host` opens a remote listener.\nRemote access uses the separate gateway, which the host user starts explicitly:\n\n```bash\n# On the host\nlats gateway start --tailnet\n\n# On the client: request access, then wait for approval on the host\nlats --host HOST pair --read-only\n# A viewer also needs screen contents (screenshots, streams, OCR):\nlats --host HOST pair --read-only --screen\n# Or request exactly these methods instead of the read-only scope:\nlats --host HOST pair --grant lattices:windows.list --grant speech:speech.enqueue\n\n# On the host: check the fingerprint and grants, then approve\nlats gateway clients\nlats gateway approve CLIENT_ID\n```\n\nThe gateway binds only the host's Tailscale address, on port 9399 by default.\nCore method names do not change. Methods of other products go through\n`gateway.call` with `{ \"product\": \"speech\", \"method\": \"speech.enqueue\", \"params\": { \"text\": \"Build finished\" } }`.\nEach grant names one exact `product:method`, and only reviewed methods can be\ngranted. Re-pairing with the same client key adds approved grants to the existing\nset. To reduce access, revoke the client and pair again. `--grant` cannot be\ncombined with `--read-only`, `--drive`, `--screen` or `--shell`.\n\nCore scopes are `read`, `screen` (screenshots, streams, OCR, terminal\ncontents), `act`, `drive` (desktop input) and `shell` (typing text and\nlaunching commands). `--read-only` requests `read`, plain `pair` requests\n`read`, `screen` and `act`, and `--drive` requests all five. Stored grants are\nexact methods, so clients paired before `screen` and `shell` were split out keep\nwhat they had. Clients paired directly with a host before this\nchange must pair again with the gateway.\n\nThe native Machines/Hosts view reuses this CLI gateway pairing and signs its\nrequests. Pairing a companion device alone does not authorize remote host\nstatus or screens. The Pi extension and Swift SDK do not add gateway signatures\nto explicit remote endpoints; a host or URL override alone is not a paired\ngateway client.\n\nRemote core event subscriptions are limited to `windows.changed`,\n`spaces.changed`, `layers.changed` and `host.healthChanged`. A client needs\n`lattices:events.subscribe` and the corresponding read grant (`windows.list`,\n`spaces.list`, `layers.list` or `daemon.status`). Unsubscribing requires\n`lattices:events.unsubscribe`. Capture stream lifecycle and activity events go\ndirectly to the connection that owns the stream; see `capture.stream.*` below.\nOther event streams stay local.\n\n## Node.js client\n\nlattices ships a zero-dependency WebSocket client that works with\nNode.js 18+. It handles connection, framing, and request/response\nmatching internally.\n\n### `daemonCall(method, params?, timeoutMs?)`\n\nSend an RPC call and await the response.\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Read-only\nconst status = await daemonCall('daemon.status')\nconst windows = await daemonCall('windows.list')\nconst win = await daemonCall('windows.get', { wid: 1234 })\n\n// Mutations\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/myapp' })\nawait daemonCall('windows.place', {\n  session: 'myapp-a1b2c3',\n  placement: { kind: 'tile', value: 'left' }\n})\n\n// Custom timeout (default: 3000ms)\nawait daemonCall('projects.scan', null, 10000)\n```\n\n**Returns** the `result` field from the response.\n**Throws** if the server returns an error, the connection fails, or the timeout is reached.\n\n### `isDaemonRunning()`\n\nCheck if the server is reachable.\n\n```js\nimport { isDaemonRunning } from '@lattices/lattices'\n\nif (await isDaemonRunning()) {\n  console.log('daemon is up')\n}\n```\n\nReturns `true` if `daemon.status` responds within 1 second.\n\n## TypeScript SDK facade\n\nThe CLI is a human/debug surface. Product code and agents should prefer the\ntyped SDK facade, which validates params with dependency-free schemas and calls\nthe same daemon methods directly.\n\n```ts\nimport { cua } from '@lattices/lattices/cua'\n\nawait cua.magicCursor({\n  app: 'Scout',\n  xRatio: 0.52,\n  yRatio: 0.91,\n  text: 'What are the most important docs in this project, and what would an agent say after reading them?',\n  treatment: 'execute',\n  trail: 'comet',\n  motion: 'rush',\n  trajectory: 'overshoot',\n  glow: 'halo',\n  idle: 'wiggle',\n  edge: 'ripple',\n})\n\nawait cua.click({\n  app: 'Scout',\n  xRatio: 0.74,\n  yRatio: 0.95,\n  transport: 'ax',\n  axLabel: 'Send',\n  noFocus: true,\n  treatment: 'execute',\n})\n```\n\n`@lattices/lattices/cua` exposes this typed CUA module. Existing imports from\n`@arach/lattices/cua` and `@lattices/cli/cua` remain supported.\n\n### Error handling\n\n`daemonCall` throws on errors — always wrap calls in try/catch:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\ntry {\n  await daemonCall('sessions.launch', { path: '/nonexistent' })\n} catch (err) {\n  // err.message is one of:\n  //   \"Not found\"              — resource doesn't exist\n  //   \"Missing parameter: ...\" — required param missing\n  //   \"Unknown method: ...\"    — bad method name\n  //   \"Daemon request timed out\" — no response within timeout\n  //   ECONNREFUSED             — daemon not running\n  console.error('Daemon error:', err.message)\n}\n```\n\n---\n\n## Runs And Capture\n\nRuns are local executions that produce trace events and artifacts. Capture\nmethods write into the Lattices run store under\n`~/Library/Application Support/Lattices/Runs/`.\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `runs.create` | write | Create a run record and artifact directory |\n| `runs.list` | read | List recent runs |\n| `runs.get` | read | Inspect one run, including artifacts and trace events |\n| `runs.artifacts` | read | List artifacts for one run |\n| `capture.screenshotWindow` | write | Capture a window screenshot as a run artifact |\n| `capture.screenshotDisplay` | write | Capture one complete display as a run artifact and optional clipboard image |\n| `capture.still` | read | One inline JPEG of a display, window or region for remote viewers; nothing is saved |\n| `capture.stream.start` | read | Stream a display, window or region as H.264 binary frames on this connection |\n| `capture.stream.ack` | read | Acknowledge frames up to `seq`; the host keeps at most 4 unacknowledged |\n| `capture.stream.keyframe` | read | Make the stream's next frame a keyframe |\n| `capture.stream.stop` | read | Stop a stream |\n| `capture.screenshotRegion` | write | Capture a screen region as a run artifact |\n| `capture.zoomArtifact` | write | Crop and zoom an image artifact into a linked artifact |\n| `vision.analyzeWindow` | write | Capture a window and run local OCR-based visual analysis |\n| `vision.analyzeArtifact` | write | Analyze an existing image artifact or path with local OCR |\n| `computer.prepare` | write | Resolve and optionally capture a terminal target without mutating it |\n| `computer.windowState` | write | Inspect a target window's AX tree and optionally write screenshot/run artifacts |\n| `computer.elementAction` | write | Stage or execute an AX action against a snapshot element id |\n| `computer.typeElement` | write | Stage or execute AXValue text insertion against a snapshot element id |\n| `computer.setValue` | write | Alias for replacing a snapshot element's AXValue |\n| `computer.pressKey` | write | Stage or execute one key press against an explicit target window |\n| `computer.hotkey` | write | Stage or execute a keyboard shortcut against an explicit target window |\n| `computer.focusWindow` | write | Resolve, capture, focus, and verify a target window |\n| `computer.showCursor` | write | Show a visible cursor appearance and record it as a run |\n| `computer.launchApp` | write | Launch or focus a normal macOS app and record the run |\n| `computer.typeWindowText` | write | Type or paste into a normal app window, optionally after a click |\n| `computer.click` | write | Stage or execute a window-relative click target; prefers no-focus `AXPress` in auto/ax transport |\n| `computer.doubleClick` | write | Stage or execute a pointer double-click target |\n| `computer.rightClick` | write | Stage or execute a right-click/context-click target |\n| `computer.scroll` | write | Stage or execute scroll wheel input |\n| `computer.drag` | write | Stage or execute a pointer drag between two points |\n| `computer.verify` | write | Verify OCR, AX, or artifact-change expectations |\n| `computer.demoScout` | write | Scout warm-up run for memo/demo recording |\n| `computer.typeText` | write | Insert text into a safe terminal using the least intrusive transport |\n| `computer.demoTerminal` | write | Compatibility wrapper for a bounded terminal text action |\n| `browser.getText` | read | Read visible browser text through Accessibility |\n| `browser.queryDom` | read | Query DOM summaries with explicit browser automation consent |\n| `browser.executeJavascript` | write | Stage or execute browser JavaScript with explicit consent |\n\n#### `capture.screenshotWindow`\n\nCapture a window as a PNG artifact. If no target is provided, Lattices captures\nthe frontmost non-Lattices window.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target CGWindowID |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title filter for `app`, or run title |\n| `runId` | string | no | Existing run to append to |\n| `source` | string | no | Calling surface label |\n| `filename` | string | no | Optional artifact filename |\n\n```js\nawait daemonCall('capture.screenshotWindow', { source: 'agent' })\n\nawait daemonCall('capture.screenshotWindow', {\n  session: 'frontend-a1b2c3',\n  filename: 'before-layout.png'\n})\n```\n\n#### `capture.still`\n\nReturn one compressed frame inline. Other machines pull these one at a time to\nshow this Mac in their remote viewer. Nothing is written to disk or the\nclipboard. Pass a window or a region, or neither for a display.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `displayIndex` | int | no | Display to capture; defaults to the display under the pointer |\n| `wid` | int | no | Window to capture instead of a display |\n| `x`, `y`, `width`, `height` | double | no | Region to capture, in layout points |\n| `maxWidth` | int | no | Largest pixel width to return; defaults to `1600` |\n| `quality` | int | no | JPEG quality from 20 to 95; defaults to `70` |\n\nThe result has `format`, `width`, `height`, base64 `data`, and `frame`: the\ncaptured rect in the same coordinates that `computer.*` input takes.\n\n#### `capture.stream.*`\n\nPush H.264 of a display, window or region as binary WebSocket messages on the\nconnection that asked, while a viewer is on screen. `capture.still` stays for\nthumbnails, idle views and hosts without streaming. `host.describe` lists\n`capture.stream` only when the host can stream now.\n\n`capture.stream.start` takes the same target fields as `capture.still`, plus:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `maxWidth` | int | no | Largest coded width, 320 to 3840; defaults to `1920` |\n| `fps` | int | no | Frame rate cap, 5 to 60; defaults to `30` |\n| `codec` | string | no | `h264`, the only codec required |\n\nThe result is `{streamId, codec, width, height, frame:{x,y,w,h}, display}`.\n`display` is `{index, name}` for a display target and null otherwise. Hosts\nthat report capture activity also include `activity`, as described below.\n\n- `capture.stream.ack {streamId, seq}` acknowledges every frame up to `seq`.\n  Send one per frame received. Hosts limit the number of frames in flight.\n  With frames outstanding and no ack for 3 s, the host ends the stream.\n  An ack for an old `seq` changes\n  nothing and still answers `{ok:true}`, so viewers use it to check that a\n  quiet stream is alive. Hosts that report capture activity include the current\n  `activity` in successful ACK responses.\n- `capture.stream.keyframe {streamId}` makes the next frame an IDR.\n- `capture.stream.stop {streamId}` stops the stream without an event.\n- Streams belong to their connection. Only that connection can ack, request a\n  keyframe or stop them, and they all stop when it closes.\n- When the host ends a stream itself, it sends\n  `{event:\"capture.stream.ended\", data:{streamId, reason}}` to the owning\n  connection, whatever it subscribed to. Reasons are `ack-timeout`,\n  `window-closed`, `encoder-failed: …` and, on a Mac, `capture-stopped: …`.\n\n**Host activity and variable frame delivery**\n\nCurrent macOS source builds suppress unchanged pictures before H.264 encoding.\nThe host compares the visible pixels of complete capture frames and accepts\nScreenCaptureKit's explicit unchanged-frame status as idle evidence. After at\nleast one second without a picture change, with unchanged-frame evidence, the\nhost reports `idle`. A pixel or capture-geometry change reports `active`\nimmediately and resumes frame delivery, subject to the ACK limit.\n\nThe capture rate remains at the requested `fps` ceiling so the host can observe\nnew changes promptly. The encoded video rate can fall to zero while the picture\nis unchanged. The viewer holds the last picture. A requested recovery keyframe\ncan still send the same picture without changing the activity state.\n\nSupporting hosts include this optional object in start and successful ACK\nresponses:\n\n```json\n{\"activity\":{\"state\":\"idle\",\"source\":\"host\",\"activeFPS\":30,\"idleFPS\":0,\"revision\":2}}\n```\n\n`state` is `active`, `idle` or `unknown`. `unknown` means the host does not have\ncurrent evidence of picture activity, including before the first frame or when\ncapture is blank or suspended. `activeFPS` is the configured frame rate ceiling;\n`idleFPS` is the intended unchanged-picture rate, currently zero. Neither field\nis a measurement of received frames.\n\n`revision` is a nonnegative integer, starting at zero for each stream and\nincreasing on each state change. Viewers ignore activity reports with an older\nrevision so a delayed ACK cannot overwrite a newer activity event.\n\nWhen the state changes, the host sends\n`{event:\"capture.stream.activity\", data:{streamId, activity}}` only to the\nconnection that owns the stream. The gateway forwards this event for streams\nowned by that viewer. No event subscription is required.\n\nOlder hosts and the current Linux host omit `activity`. Viewers must not infer\n`idle` from missing frames, low received FPS or an omitted activity object.\nThose hosts do not promise the macOS unchanged-picture suppression behavior.\n\n**Binary frames**\n\nEach binary message is one access unit, big-endian:\n\n| Offset | Size | Field |\n|--------|------|-------|\n| 0 | 4 | `\"LVF1\"` |\n| 4 | u32 | `streamId` |\n| 8 | u32 | `seq`, per stream, from 1 |\n| 12 | u8 | flags: bit 0 keyframe, bit 1 parameter sets follow |\n| 13 | u8 | codec: 1 H.264, 2 HEVC |\n| 14 | u16 | reserved, 0 |\n| 16 | u64 | `pts`, microseconds on the host clock |\n| 24 | u32 | width of the coded picture |\n| 28 | u32 | height |\n| 32 | 4 × f32 | `frame` x, y, w, h: the host-space rect the picture shows |\n| 48 | | if bit 1: u16 count, then u16 length + bytes for each parameter set (SPS, PPS; VPS first for HEVC) |\n| | | the access unit: NAL units, each with a u32 length prefix (AVCC, not Annex B) |\n\nEvery keyframe carries its parameter sets. There are no B-frames. The codec\nlives in `apps/mac/Sources/Core/Hosts/RemoteVideoFrame.swift` and\n`packages/host-linux/src/stream.ts`.\n\n#### `capture.screenshotDisplay`\n\nCapture one complete composited display as a PNG artifact without focusing a\nwindow or changing visible overlays. The image includes the menu bar, pointer\nby default, and Lattices-owned surfaces already visible on that display, which\nmakes it suitable for snapshots of transient UI such as Hyper+G. When no\ndisplay is supplied, Lattices captures the display under the pointer.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `displayIndex` | int | no | Zero-based target from `spaces.list`; defaults to the display under the pointer |\n| `display` | int | no | Alias for `displayIndex` |\n| `cursor` | bool | no | Include the pointer; defaults to `true` |\n| `clipboard` | bool | no | Copy PNG image data to the system clipboard; defaults to `true` for raw RPC calls |\n| `title` | string | no | Optional run title |\n| `runId` | string | no | Existing run to append to |\n| `source` | string | no | Calling surface label |\n| `filename` | string | no | Optional artifact filename |\n\n```js\nawait daemonCall('capture.screenshotDisplay', {\n  displayIndex: 0,\n  clipboard: true,\n  filename: 'hyper-g-layout.png',\n  source: 'agent'\n})\n```\n\nThe CLI uses the positional index as `display`, always writes the run artifact,\nand copies the PNG only when `--clipboard` is present. `--delay` is a CLI-side\nwait measured in seconds; it is not an RPC parameter.\n\n```bash\nlats capture display 0 --clipboard --delay 3 --filename hyper-g-layout.png\n```\n\n#### `capture.screenshotRegion`\n\nCapture an explicit screen rectangle as a PNG artifact. If no rectangle is\nprovided, the endpoint resolves `wid`/`session`/`app`/frontmost target and\ncaptures that window frame.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `x`, `y` | double | no | Region origin in screen coordinates |\n| `width`, `height` | double | no | Region size |\n| `w`, `h` | double | no | Size aliases |\n| `wid`, `session`, `app`, `title` | target | no | Target window fallback |\n| `runId`, `source`, `filename` | mixed | no | Standard run/artifact fields |\n\n```js\nawait daemonCall('capture.screenshotRegion', {\n  x: 120,\n  y: 160,\n  width: 640,\n  height: 420,\n  filename: 'composer-region.png'\n})\n```\n\n#### `capture.zoomArtifact`\n\nCrop and zoom an existing image artifact into a new PNG artifact on the same\nrun. Coordinates are image pixels; ratio fields are relative to the source\nimage.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `runId` | string | no | Run containing the artifact |\n| `artifactId` | string | no | Source artifact id |\n| `path` | string | no | Source image path fallback |\n| `x`, `y`, `width`, `height` | double | no | Crop rectangle in image pixels |\n| `xRatio`, `yRatio`, `widthRatio`, `heightRatio` | double | no | Crop rectangle as image ratios |\n| `scale` | double | no | Zoom scale, default `2`, max `8` |\n| `filename`, `source` | string | no | Output artifact metadata |\n\n```js\nawait daemonCall('capture.zoomArtifact', {\n  runId: 'run_...',\n  artifactId: 'art_...',\n  xRatio: 0.35,\n  yRatio: 0.7,\n  widthRatio: 0.3,\n  heightRatio: 0.2,\n  scale: 3\n})\n```\n\n#### `vision.analyzeWindow` / `vision.analyzeArtifact`\n\nRun explicit local OCR-based visual analysis. These endpoints do not call a\ncloud vision model; they use the same on-device Vision OCR path as Lattices OCR\nand return `fullText`, text blocks, a short answer, and optional `verified`\nwhen `contains` or `notContains` is supplied.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `instruction` | string | yes | Question or analysis instruction |\n| `wid`, `session`, `app`, `title` | target | no | Window target for `vision.analyzeWindow` |\n| `runId`, `artifactId`, `path` | mixed | no | Artifact target for `vision.analyzeArtifact` |\n| `contains`, `notContains` | string | no | Optional text expectation |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('vision.analyzeWindow', {\n  app: 'Safari',\n  instruction: 'Read the visible page heading',\n  contains: 'Documentation'\n})\n```\n\nCLI:\n\n```bash\nlats capture window\nlats capture display 0 --clipboard --delay 3\nlats runs\nlats runs run_20260617-120000_a1b2c3\nlats terminals\nlats terminals --refresh\nlats computer prepare --text \"# hello\" --treatment stage\nlats call computer.windowState '{\"app\":\"Finder\",\"maxDepth\":4}'\nlats call computer.pressKey '{\"app\":\"Finder\",\"key\":\"escape\",\"treatment\":\"stage\"}'\nlats call computer.hotkey '{\"app\":\"Xcode\",\"shortcut\":\"command+b\",\"treatment\":\"stage\"}'\nlats computer focus-window --wid 7258 --treatment present\nlats computer cursor --style marker --shape chevron --angle-deg -8 --label typing\nlats computer launch-app Scout\nlats computer scout --treatment present\nlats computer scout \"Draft memo text\" --execute\nlats computer type-window --app Scout --text \"Draft memo text\" --x-ratio .5 --y-ratio .86 --execute\nlats computer click --app Scout --x-ratio .5 --y-ratio .86 --execute\nlats cua click --app Scout --x-ratio .74 --y-ratio .95 --transport ax --ax-label Send --execute\nlats call computer.scroll '{\"app\":\"Safari\",\"direction\":\"down\",\"amount\":420,\"treatment\":\"stage\"}'\nlats call computer.drag '{\"app\":\"Finder\",\"fromXRatio\":0.2,\"fromYRatio\":0.2,\"toXRatio\":0.7,\"toYRatio\":0.7,\"treatment\":\"stage\"}'\nlats call computer.doubleClick '{\"app\":\"Finder\",\"xRatio\":0.5,\"yRatio\":0.5,\"treatment\":\"stage\"}'\nlats call computer.rightClick '{\"app\":\"Finder\",\"xRatio\":0.5,\"yRatio\":0.5,\"treatment\":\"stage\"}'\nlats call computer.verify '{\"app\":\"Safari\",\"mode\":\"ocr\",\"contains\":\"Docs\"}'\nlats call capture.screenshotRegion '{\"app\":\"Safari\",\"filename\":\"safari-region.png\"}'\nlats call vision.analyzeWindow '{\"app\":\"Safari\",\"instruction\":\"Read visible text\"}'\nlats call browser.getText '{\"app\":\"Safari\"}'\nlats call browser.queryDom '{\"app\":\"Safari\",\"selector\":\"h1\",\"allowAutomation\":true}'\nlats computer type-text --text \"# hello from lattices\"\nlats computer demo-terminal --dry-run\n```\n\n---\n\n#### Computer Action Treatments\n\nComputer-use endpoints accept a `treatment` field that controls how intrusive\nthe action may be:\n\n| Treatment | Behavior |\n|-----------|----------|\n| `observe` | Resolve target and record a run, without focus or input |\n| `stage` | Resolve target and stage intent/artifacts, without focus or input |\n| `present` | Focus or present the target, without input |\n| `execute` | Perform the action after safety checks |\n\nThe legacy `dryRun: true` flag maps to `stage`.\n\n#### Background-Safety Receipts\n\nComputer-use action responses include a `backgroundSafety` receipt plus\ntop-level compatibility fields:\n\n```json\n{\n  \"backgroundSafety\": {\n    \"route\": \"ax.press\",\n    \"lane\": \"same_session\",\n    \"session\": \"parent\",\n    \"background_safe\": true,\n    \"backgroundSafe\": true,\n    \"cursor_moved\": false,\n    \"foreground_changed\": false\n  }\n}\n```\n\nAgents should treat `background_safe: true` as the signal that the call stayed\nout of the user's foreground session. AX, tmux, iTerm session, overlay, staged,\nand observe routes can be background-safe. Pointer input, app activation,\nwindow focus, global keyboard input, and foreground typing report\n`background_safe: false`.\n\n#### `computer.prepare`\n\nResolve and score terminal candidates for a future computer-use action. This is\nthe least intrusive endpoint: by default it observes the target and captures an\nartifact, but it does not focus or type.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Specific terminal window id |\n| `tty` | string | no | Specific terminal TTY |\n| `app` | string | no | Preferred terminal app, such as `iTerm2` |\n| `text` | string | no | Text to stage in the run trace |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `capture` | bool | no | Capture target screenshot artifact. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.prepare', {\n  text: '# review before typing',\n  treatment: 'stage'\n})\n```\n\n#### `computer.windowState`\n\nInspect a target window's Accessibility tree and return snapshot-local element\nids (`e1`, `e2`, ...), a flat `elements` list, and a compact `treeMarkdown`\nview. `mode: \"ax\"` avoids Screen Recording. Use `mode: \"both\"` or\n`capture: true` when you also want a screenshot artifact linked to a run. The\nendpoint is classified as a write because those capture modes create run\nartifacts.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `mode` | string | no | `ax` (default), `both`, or `screenshot` |\n| `capture` | bool | no | Capture a screenshot artifact. Defaults true for `both`/`screenshot`, false for `ax` |\n| `maxDepth` | int | no | Maximum AX tree depth. Defaults to `8`, max `14` |\n| `maxElements` | int | no | Maximum elements returned. Defaults to `250`, max `1000` |\n| `timeoutMs` | int | no | Traversal timeout. Defaults to `1200`, max `5000` |\n| `source` | string | no | Calling surface label when capture creates a run |\n\n```js\nawait daemonCall('computer.windowState', {\n  app: 'Finder',\n  maxDepth: 4,\n  maxElements: 120\n})\n\nawait daemonCall('computer.windowState', {\n  wid: 7258,\n  mode: 'both',\n  source: 'agent'\n})\n```\n\n#### `computer.elementAction`\n\nStage or execute an Accessibility action against an element returned by a recent\n`computer.windowState` call. Snapshot element ids are intentionally local to the\ndaemon process and expire from the in-memory cache after a short window.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `snapshotId` | string | yes | Snapshot id returned by `computer.windowState` |\n| `elementId` | string | yes | Snapshot-local element id, such as `e4` |\n| `action` | string | no | `press` (default), `showMenu`, or `focus` |\n| `treatment` | string | no | `stage`, `present`, or `execute`. Defaults to `stage` |\n| `backgroundSafe` / `noFocus` | bool | no | With `execute`, perform AX actions without focusing the app when the requested action can run in the background |\n| `dryRun` | bool | no | Stage without performing the action |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nconst state = await daemonCall('computer.windowState', {\n  app: 'Calculator',\n  maxDepth: 6\n})\n\nawait daemonCall('computer.elementAction', {\n  snapshotId: state.snapshotId,\n  elementId: 'e7',\n  action: 'press',\n  treatment: 'execute',\n  backgroundSafe: true\n})\n```\n\n#### `computer.typeElement` / `computer.setValue`\n\nStage or execute text/value insertion against an element returned by a recent\n`computer.windowState` call. `computer.typeElement` accepts `text`;\n`computer.setValue` accepts `value` and otherwise uses the same behavior. In\n`stage`, Lattices records the intended value without changing the app.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `snapshotId` | string | yes | Snapshot id returned by `computer.windowState` |\n| `elementId` | string | yes | Snapshot-local element id, such as `e4` |\n| `text` | string | yes for `typeElement` | Text to set or append |\n| `value` | string | yes for `setValue` | Value to set or append |\n| `append` | bool | no | Append to current `AXValue` instead of replacing it |\n| `typeIntervalMs` | double | no | Per-character interval for typewriter-style AXValue updates |\n| `treatment` | string | no | `stage`, `present`, or `execute`. Defaults to `stage` |\n| `backgroundSafe` / `noFocus` | bool | no | With `execute`, set `AXValue` without focusing the app or AX element |\n| `dryRun` | bool | no | Stage without setting `AXValue` |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nconst state = await daemonCall('computer.windowState', {\n  app: 'Notes',\n  maxDepth: 8\n})\n\nawait daemonCall('computer.typeElement', {\n  snapshotId: state.snapshotId,\n  elementId: 'e12',\n  text: 'Draft note',\n  treatment: 'execute',\n  backgroundSafe: true\n})\n```\n\n#### `computer.pressKey` / `computer.hotkey`\n\nStage or execute keyboard input. `computer.pressKey` sends one key, such as\n`escape`, `enter`, `tab`, an arrow key, or a single character. `computer.hotkey`\nsends a shortcut from either `shortcut: \"command+shift+p\"` or `key` plus\n`modifiers`. Both default to `stage`; `execute` requires an explicit `wid`,\n`app`, or `session` target unless `allowGlobal: true` is passed.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `key` | string | yes for `pressKey` | Key name or single character |\n| `shortcut` | string | yes for `hotkey` without `key` | Shortcut shorthand, such as `command+shift+p` |\n| `modifiers` | array/string | no | `command`, `option`, `control`, `shift` |\n| `count` | int | no | Repeat count. Defaults to `1`, max `20` |\n| `delayMs` | double | no | Delay between repeats. Defaults to `80` |\n| `allowGlobal` | bool | no | Permit execute without target by posting to the focused system target |\n| `treatment` | string | no | `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without posting keyboard input |\n| `capture` | bool | no | Capture before/after artifacts when targeting a window |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.pressKey', {\n  app: 'Finder',\n  key: 'escape',\n  treatment: 'stage'\n})\n\nawait daemonCall('computer.hotkey', {\n  app: 'Xcode',\n  shortcut: 'command+b',\n  treatment: 'execute'\n})\n```\n\n#### `computer.focusWindow`\n\nResolve a target window, optionally capture it, focus it, and verify the focused\nwindow id. Use `treatment: 'observe'` or `stage` to plan without presenting.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for `app` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without focusing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.focusWindow', {\n  app: 'iTerm2',\n  treatment: 'present'\n})\n```\n\n#### `computer.showCursor`\n\nResolve the current cursor location and show a visible cursor appearance. This\nis the cursor equivalent of a typing action: it records a run, cursor target,\nappearance parameters, and trace events. Use `observe` or `stage` to plan\nwithout showing anything.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `x` | double | no | Screen x coordinate. Defaults to current cursor |\n| `y` | double | no | Screen y coordinate. Defaults to current cursor |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `style` | string | no | `spotlight`, `pulse`, or `marker` |\n| `appearance` | string | no | Alias for `style` |\n| `shape` | string | no | Marker shape: `chevron`, `facet`, `shard`, `wedge`, `prism`, or `notch` |\n| `angleDeg` | double | no | Marker rotation in degrees. Positive rotates visually clockwise; default is `-8` for marker |\n| `size` | string | no | Marker size: `small`, `regular`, or `large`. Defaults to Settings |\n| `color` | string | no | `blue`, `green`, `amber`, `pink`, `red`, or `white` |\n| `durationMs` | int | no | Appearance duration in milliseconds |\n| `label` | string | no | Optional marker label |\n| `dryRun` | bool | no | Stage without showing |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.showCursor', {\n  style: 'marker',\n  shape: 'chevron',\n  angleDeg: -8,\n  size: 'regular',\n  color: 'white',\n  treatment: 'present'\n})\n```\n\n#### `computer.launchApp`\n\nLaunch or focus a normal macOS app and record the result as a run. Use\n`dryRun: true` or `treatment: 'stage'` to plan without launching.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `app` | string | yes | App name, such as `Scout`, `Slack`, or `Notes` |\n| `bundleId` | string | no | Bundle identifier fallback for precise launch |\n| `path` | string | no | Explicit `.app` bundle path |\n| `title` | string | no | Optional title substring for app window selection |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without launching |\n| `capture` | bool | no | Capture the launched app window. Defaults to `true` outside dry-run |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.launchApp', {\n  app: 'Scout',\n  treatment: 'present'\n})\n```\n\n#### `computer.typeWindowText`\n\nFocus a normal app window and insert text. If click coordinates are provided,\nLattices clicks that target before typing. Coordinates can be absolute screen\npoints (`x`, `y`) or ratios inside the target window (`xRatio`, `yRatio`).\nFor window ratios, `0,0` is the top-left of the window and `1,1` is the\nbottom-right.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `text` | string | yes | Text to insert |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `send` | bool | no | Alias for `enter` in chat-style demos |\n| `x`, `y` | double | no | Absolute click point before typing |\n| `xRatio`, `yRatio` | double | no | Window-relative click point before typing |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.typeWindowText', {\n  app: 'Scout',\n  text: 'Draft memo text',\n  xRatio: 0.5,\n  yRatio: 0.86,\n  treatment: 'execute'\n})\n```\n\n#### `computer.click` / `computer.doubleClick` / `computer.rightClick`\n\nStage or execute a click target. `stage` records the target without clicking.\nIn `execute`, `transport: \"auto\"` prefers `AXPress` on the resolved accessibility\nbutton/control before falling back to a pointer click. Use `transport: \"ax\"` or\n`noFocus: true` when the action must not focus the app or move the hardware\npointer. `computer.doubleClick` forces two pointer clicks; `computer.rightClick`\nforces the right button. When a window target is provided, ratios are relative to\nthat window.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Target window id |\n| `session` | string | no | Target lattices session |\n| `app` | string | no | Target app name |\n| `title` | string | no | Optional title substring for app target |\n| `x`, `y` | double | no | Absolute click point |\n| `xRatio`, `yRatio` | double | no | Window-relative click point |\n| `button` | string | no | `left` or `right`; defaults to `left` |\n| `count` | int | no | Pointer click count. Defaults to `1`, max `8` |\n| `delayMs` | double | no | Delay between repeated pointer clicks |\n| `transport` | string | no | `auto`, `ax`, or `pointer`; defaults to `auto` |\n| `axLabel` | string | no | Optional AX text/title hint, such as `Send` |\n| `noFocus` | bool | no | Require no-focus AX execution; disable pointer fallback |\n| `treatment` | string | no | `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without clicking |\n| `capture` | bool | no | Capture before/after artifacts when targeting a window |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.click', {\n  app: 'Scout',\n  xRatio: 0.5,\n  yRatio: 0.86,\n  treatment: 'execute'\n})\n\nawait daemonCall('computer.click', {\n  app: 'Scout',\n  xRatio: 0.74,\n  yRatio: 0.95,\n  transport: 'ax',\n  axLabel: 'Send',\n  noFocus: true,\n  treatment: 'execute'\n})\n```\n\n#### `computer.scroll` / `computer.drag`\n\nStage or execute richer pointer input while keeping the same run/capture model.\n`computer.scroll` posts scroll wheel events at an absolute or window-relative\npoint. `computer.drag` interpolates a pointer drag from a start point to an end\npoint. Both default to `stage`.\n\n**Scroll params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid`, `session`, `app`, `title` | target | no | Target window/session/app |\n| `x`, `y` | double | no | Absolute pointer point before scrolling |\n| `xRatio`, `yRatio` | double | no | Window-relative pointer point |\n| `direction` | string | no | `down` (default), `up`, `left`, or `right` |\n| `amount` | double | no | Scroll amount when using `direction`; default `420` |\n| `deltaX`, `deltaY` | double | no | Explicit scroll wheel deltas |\n| `count` | int | no | Number of scroll events; default `1`, max `30` |\n| `delayMs` | double | no | Delay between repeated events |\n| `treatment`, `dryRun`, `capture`, `source` | mixed | no | Standard computer action fields |\n\n**Drag params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid`, `session`, `app`, `title` | target | no | Target window/session/app |\n| `fromX`, `fromY` | double | no | Absolute drag start point |\n| `toX`, `toY` | double | no | Absolute drag end point |\n| `fromXRatio`, `fromYRatio` | double | no | Window-relative drag start point |\n| `toXRatio`, `toYRatio` | double | no | Window-relative drag end point |\n| `x`, `y`, `xRatio`, `yRatio` | double | no | Aliases for the drag end point |\n| `button` | string | no | `left` or `right`; defaults to `left` |\n| `durationMs` | double | no | Drag duration; default `360` |\n| `steps` | int | no | Interpolated drag event count; default `18` |\n| `treatment`, `dryRun`, `capture`, `source` | mixed | no | Standard computer action fields |\n\n```js\nawait daemonCall('computer.scroll', {\n  app: 'Safari',\n  direction: 'down',\n  amount: 420,\n  treatment: 'execute'\n})\n\nawait daemonCall('computer.drag', {\n  app: 'Finder',\n  fromXRatio: 0.2,\n  fromYRatio: 0.2,\n  toXRatio: 0.7,\n  toYRatio: 0.7,\n  treatment: 'stage'\n})\n```\n\n#### `computer.verify`\n\nVerify a post-action outcome using OCR, AX snapshot text/value, or artifact hash\ncomparison. OCR verification captures/analyzes the target when needed. AX\nverification can use a `snapshotId`/`elementId` from `computer.windowState`.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `mode` | string | no | `ocr` (default), `ax`, or `artifactChanged` |\n| `wid`, `session`, `app`, `title` | target | no | Window target |\n| `snapshotId`, `elementId` | string | no | AX snapshot/element target |\n| `runId`, `artifactId`, `path` | mixed | no | Image artifact target for OCR verification |\n| `contains`, `expected` | string | no | Text that should be present |\n| `notContains` | string | no | Text that should be absent |\n| `beforeArtifactId`, `afterArtifactId` | string | no | Artifact ids for `artifactChanged` |\n| `beforePath`, `afterPath` | string | no | Path fallback for `artifactChanged` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.verify', {\n  app: 'Safari',\n  mode: 'ocr',\n  contains: 'Lattices'\n})\n\nconst state = await daemonCall('computer.windowState', { app: 'Notes' })\nawait daemonCall('computer.verify', {\n  mode: 'ax',\n  snapshotId: state.snapshotId,\n  elementId: 'e4',\n  contains: 'Draft'\n})\n```\n\n#### `browser.getText` / `browser.queryDom` / `browser.executeJavascript`\n\nBrowser primitives are intentionally gated. `browser.getText` reads visible text\nthrough Accessibility and does not enable JavaScript automation. DOM querying\nand arbitrary JavaScript use AppleScript browser automation and require\n`allowAutomation: true`; JavaScript mutation also requires\n`treatment: \"execute\"`.\n\nSupported browser app names: `Safari`, `Google Chrome`, `Chrome`, `Brave\nBrowser`, `Microsoft Edge`, and `Arc`.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid`, `app`, `title` | target | no | Browser target |\n| `selector` | string | yes for `queryDom` | CSS selector |\n| `limit` | int | no | Max DOM nodes, default `20`, max `200` |\n| `script` | string | yes for `executeJavascript` | JavaScript source |\n| `allowAutomation` | bool | yes for DOM/execute | Explicit browser automation consent |\n| `treatment` | string | no | `stage` (default) or `execute` for JavaScript |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('browser.getText', { app: 'Safari' })\n\nawait daemonCall('browser.queryDom', {\n  app: 'Safari',\n  selector: 'h1, main a',\n  allowAutomation: true\n})\n\nawait daemonCall('browser.executeJavascript', {\n  app: 'Safari',\n  script: 'document.title',\n  treatment: 'execute',\n  allowAutomation: true\n})\n```\n\n#### `computer.demoScout`\n\nWarm up a Scout memo/demo recording run. In `present` mode it launches or\nfocuses Scout and records a run without typing. In `execute` mode it can click\nthe likely composer area, type a draft, and optionally press Enter when\n`enter` or `send` is true. Dry-run/stage mode does not capture by default, so it\nworks before Screen Recording permission is granted.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `app` | string | no | Scout app name override. Defaults to `Scout` |\n| `title` | string | no | Optional title substring for the Scout window |\n| `text` | string | no | Draft text to type in `execute` mode |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `send` | bool | no | Alias for `enter` |\n| `click` | bool | no | Click the likely composer area before typing |\n| `xRatio`, `yRatio` | double | no | Composer click point; defaults to `0.5`, `0.86` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `dryRun` | bool | no | Stage without launching or typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` outside dry-run |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.demoScout', { dryRun: true })\n\nawait daemonCall('computer.demoScout', {\n  treatment: 'present',\n  capture: false\n})\n\nawait daemonCall('computer.demoScout', {\n  text: 'Draft memo text',\n  treatment: 'execute',\n  send: false\n})\n```\n\n#### `computer.typeText`\n\nResolve a terminal target and insert text after safety checks. Lattices prefers\nthe least intrusive available transport: tmux pane input when a tmux pane is\nknown, then target-pid key events/pasteboard insertion when window focus is\nrequired. Enter is never pressed unless `enter: true` is provided.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Specific terminal window id |\n| `tty` | string | no | Specific terminal TTY |\n| `app` | string | no | Preferred terminal app, such as `iTerm2` |\n| `text` | string | yes | Text to insert |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `transport` | string | no | `auto`, `tmux`, or `pasteboard`. Defaults to `auto` |\n| `dryRun` | bool | no | Stage without typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.typeText', {\n  text: '# hello from lattices',\n  transport: 'auto',\n  enter: false\n})\n```\n\n#### `computer.demoTerminal`\n\nCompatibility endpoint for the original terminal demo. It follows the same\ntreatment, safety, capture, and transport rules as `computer.typeText`, but\nprovides a default text payload when `text` is omitted.\n\nRun a bounded computer-use sequence against a terminal window:\n\n1. synthesize and score terminal candidates\n2. choose a safe shell-like terminal unless `wid` or `tty` is supplied\n3. capture a `before` screenshot artifact\n4. focus the terminal window\n5. insert text without pressing Enter by default\n6. capture an `after` screenshot artifact\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | uint32 | no | Specific terminal window id |\n| `tty` | string | no | Specific terminal TTY |\n| `app` | string | no | Preferred terminal app, such as `iTerm2` |\n| `text` | string | no | Text to insert |\n| `enter` | bool | no | Press Enter after typing. Defaults to `false` |\n| `treatment` | string | no | `observe`, `stage`, `present`, or `execute` |\n| `transport` | string | no | `auto`, `tmux`, or `pasteboard`. Defaults to `auto` |\n| `dryRun` | bool | no | Plan and capture without typing |\n| `capture` | bool | no | Capture before/after artifacts. Defaults to `true` |\n| `source` | string | no | Calling surface label |\n\n```js\nawait daemonCall('computer.demoTerminal', { dryRun: true })\n\nawait daemonCall('computer.demoTerminal', {\n  app: 'iTerm2',\n  text: '# hello from lattices',\n  enter: false\n})\n```\n\n---\n\n## Overlay UI\n\nThe macOS app exposes a shared desktop overlay canvas for lightweight\nagent-visible UI. Use `overlay.publish` for transient passive visuals,\nand `overlay.actor.*` for persistent, movable actor surfaces.\n\nPersistent actors are useful for representing agents or processes on the\ndesktop. Each actor has a stable `id`, can be moved independently through the\nAPI, dragged by the user, hidden/restored with **Hyper+B**, and closed with\nright-click. Click event callbacks and action surfaces are planned follow-on\ncapabilities.\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `overlay.publish` | write | Publish a transient toast, label, highlight, or pet layer |\n| `overlay.clear` | write | Clear one overlay layer by id, or clear an owner namespace |\n| `overlay.actor.publish` | write | Create or update a persistent generative overlay actor |\n| `overlay.actor.moveTo` | write | Move an actor with app-owned easing |\n| `overlay.actor.hud` | write | Attach, update, or clear a hover web HUD for an actor |\n| `overlay.actor.visibility` | write | Show, hide, toggle, or inspect the sticky actor layer |\n\n#### `overlay.publish`\n\nPublish a transient layer on the screen overlay canvas.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `kind` | string | yes | `toast`, `label`, `highlight`, or `pet` |\n| `id` | string | no | Stable layer id; generated if omitted |\n| `text` | string | no | Toast/label text or pet message fallback |\n| `detail` | string | no | Secondary toast/label text |\n| `message` | string | no | Pet message |\n| `petId` | string | no | Bundled pet id from `apps/mac/Resources/Pets` |\n| `state` | string | no | Pet animation state |\n| `placement` | string | no | `top`, `bottom`, `center`, `cursor`, or `point` |\n| `x`, `y` | double | no | Screen-local point for `point` placement |\n| `w`, `h` | double | no | Highlight size |\n| `ttlMs` | int | no | Time to live in milliseconds |\n| `dismissible` | bool | no | Whether click-away dismissal removes the layer; defaults `true` |\n\nExample:\n\n```js\nawait daemonCall('overlay.publish', {\n  kind: 'highlight',\n  x: 160,\n  y: 120,\n  w: 480,\n  h: 260,\n  text: 'Needs review',\n  style: 'warning',\n  ttlMs: 3000\n})\n```\n\n#### `overlay.actor.publish`\n\nCreate or update a generative overlay actor. Actors default to persistent:\nomit `ttlMs` or pass `0`, and `dismissible` defaults to `false`.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `id` | string | no | Stable actor id; generated if omitted |\n| `renderer` | string | no | `sprite` is currently supported |\n| `asset` | string | no | Bundled sprite asset id, such as `scout-ranger` |\n| `state` | string | no | Actor state or animation name |\n| `name` | string | no | Actor display name |\n| `message` | string | no | Attached message text |\n| `targetApp` | string | no | App name to activate when the actor is clicked |\n| `targetBundleId` | string | no | Bundle identifier to activate when the actor is clicked |\n| `targetAppPath` | string | no | `.app` bundle path to open when the actor is clicked |\n| `scale` | double | no | Actor scale multiplier |\n| `labelHidden` | bool | no | Hide the actor label/message |\n| `closeOnActivate` | bool | no | Remove the actor after activating its target app |\n| `hudUrl` | string | no | URL to render in a transparent hover HUD web view |\n| `hudHTML` | string | no | Inline HTML to render in a transparent hover HUD web view |\n| `hudWidth` | double | no | Hover HUD width |\n| `hudHeight` | double | no | Hover HUD height |\n| `hudReadAccess` | string | no | Local folder a file-backed HUD may read |\n| `placement` | string | no | `top`, `bottom`, `center`, `cursor`, or `point` |\n| `x`, `y` | double | no | Screen-local point for `point` placement |\n| `ttlMs` | int | no | Time to live; `0` means persistent |\n| `dismissible` | bool | no | Whether click-away dismissal removes the actor |\n\nBundled sprite assets:\n\n| Asset | Notes |\n|-------|-------|\n| `assistant-spark` | Animated states include `idle`, `run_right`, `run_left`, `waving`, `jumping`, `failed`, `waiting`, `running`, and `review` |\n| `scout-ranger` | Bundled sprite asset with default frame fallback |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.publish', {\n  id: 'agent-scout',\n  renderer: 'sprite',\n  asset: 'scout-ranger',\n  state: 'waiting',\n  name: 'Scout',\n  message: 'Waiting for feedback',\n  placement: 'point',\n  x: 640,\n  y: 320,\n  ttlMs: 0\n})\n```\n\n#### `overlay.actor.moveTo`\n\nMove an actor with app-owned animation. The app interpolates position and\nswitches directional sprite states while moving.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `id` | string | yes | Actor id |\n| `x`, `y` | double | yes | Target screen-local point |\n| `durationMs` | int | no | Animation duration |\n| `easing` | string | no | `linear`, `easeInOut`, or `spring` |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.moveTo', {\n  id: 'agent-scout',\n  x: 820,\n  y: 280,\n  durationMs: 800,\n  easing: 'spring'\n})\n```\n\n#### `overlay.actor.hud`\n\nAttach a transparent, blurred hover HUD to an actor. The HUD is backed by a\nnative `WKWebView`, so apps can point it at a local static HTML dashboard or,\nin development, a local URL.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `id` | string | yes | Actor id |\n| `hudUrl` | string | no | URL or file path to render |\n| `hudHTML` | string | no | Inline HTML to render instead of a URL |\n| `hudTitle` | string | no | HUD title metadata |\n| `hudWidth` | double | no | HUD width |\n| `hudHeight` | double | no | HUD height |\n| `hudReadAccess` | string | no | Local folder a file-backed HUD may read |\n| `clear` | bool | no | Remove the actor HUD |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.hud', {\n  id: 'switch-talkie',\n  hudUrl: '/Users/you/dev/talkie/.lattices/hud/index.html',\n  hudReadAccess: '/Users/you/Library/Application Support/Talkie/HUD',\n  hudWidth: 380,\n  hudHeight: 260\n})\n```\n\n#### `overlay.actor.visibility`\n\nShow, hide, toggle, or inspect the persistent actor layer without destroying\nthe actors. This is the daemon equivalent of the app's **Hyper+B** shortcut.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `action` | string | no | `show`, `hide`, `toggle`, or `status` |\n| `visible` | bool | no | Set layer visibility directly |\n| `hidden` | bool | no | Set layer hidden state directly |\n| `feedback` | bool | no | Show a short desktop feedback toast |\n\nExample:\n\n```js\nawait daemonCall('overlay.actor.visibility', { action: 'toggle' })\n```\n\n### Static HUD Manifests\n\nApps and projects can expose a local hover dashboard without running a web\nserver by publishing a static bundle at:\n\n```txt\n.lattices/hud/\n  manifest.json\n  index.html\n  assets/\n```\n\nMinimal manifest:\n\n```json\n{\n  \"version\": 1,\n  \"id\": \"talkie\",\n  \"name\": \"Talkie\",\n  \"bundleId\": \"com.usabletalkie.Talkie\",\n  \"icon\": \"./assets/icon.png\",\n  \"entry\": \"./index.html\",\n  \"readAccess\": \"~/Library/Application Support/Talkie/HUD\",\n  \"surface\": { \"width\": 380, \"height\": 260 },\n  \"actor\": {\n    \"labelHidden\": true,\n    \"click\": \"activateApp\"\n  },\n  \"sources\": [\n    {\n      \"path\": \"~/Library/Application Support/Talkie/HUD/activity.jsonl\",\n      \"format\": \"jsonl\",\n      \"schema\": \"talkie.activity.v1\",\n      \"presentation\": \"timeline\"\n    }\n  ]\n}\n```\n\nThe CLI resolves this manifest into `overlay.actor.publish` with a file-backed\nHUD URL. The macOS app loads `entry` through `WKWebView.loadFileURL`, allowing\nread access to the HUD folder by default, or to the manifest's `readAccess`\nfolder when one is declared.\n\n`sources` is descriptive metadata for app-owned state, logs, or event streams.\nLattices does not append to those logs. The app writes them in its normal\nruntime location, and the custom HUD renderer decides how to present them.\n\nUseful commands:\n\n```bash\nlats hud register .lattices/hud/manifest.json --publish\nlats hud publish talkie\nlats hud sync\nlats hud discover ~/dev --register\n```\n\nFor packaged apps, keep the renderer files in the app bundle and point mutable\nsources at an app-owned folder such as `~/Library/Application Support/...`.\n\n---\n\n## System\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `deck.manifest` | read | Shared companion deck manifest |\n| `deck.snapshot` | read | Current companion deck runtime snapshot |\n| `deck.perform` | write | Perform a companion deck action |\n| `solo.status` | read | Check whether Focus Mode is active |\n| `stage.status` | read | Check whether Stage is active |\n| `stage.enter` | write | Enter Stage for the windows on this display |\n| `stage.exit` | write | Exit Stage and restore the windows |\n| `stage.toggle` | write | Toggle Stage |\n| `stage.cycle` | write | Move Stage to another window in the set |\n| `solo.enter` | write | Spotlight the frontmost window |\n| `solo.exit` | write | Exit Focus Mode and restore the window |\n| `solo.toggle` | write | Toggle Focus Mode for the frontmost window |\n| `daemon.status` | read | Health check and stats |\n| `api.schema` | read | Full API schema for self-discovery |\n| `diagnostics.list` | read | Recent diagnostic entries |\n\n#### `deck.manifest`\n\nReturn the shared `DeckKit` manifest exposed by the macOS app. This is\nthe contract a future Lattices companion can consume to discover pages,\ncapabilities, and security mode.\n\n**Params**: none\n\n#### `deck.snapshot`\n\nReturn the current `DeckKit` runtime snapshot for the macOS host.\n\n**Params**: none\n\n**Returns**: a `DeckRuntimeSnapshot` object containing:\n\n- `voice`\n- `desktop`\n- `switcher`\n- `history`\n- `questions`\n\n#### `deck.perform`\n\nPerform a `DeckKit` action against the running macOS host and return a\n`DeckActionResult`.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `pageID` | string | no | Deck page ID |\n| `actionID` | string | yes | Deck action identifier |\n| `payload` | object | no | Deck action payload |\n\nExample:\n\n```json\n{\n  \"id\": \"1\",\n  \"method\": \"deck.perform\",\n  \"params\": {\n    \"pageID\": \"layout\",\n    \"actionID\": \"layout.optimize\",\n    \"payload\": {\n      \"strategy\": \"balanced\",\n      \"region\": \"right\"\n    }\n  }\n}\n```\n\n#### `solo.status`, `solo.enter`, `solo.exit`, `solo.toggle`\n\nControl the same reversible Focus Mode exposed through the **Hyper+Z** chord\nand command palette. `solo.enter` targets the currently frontmost window;\n`solo.exit` restores the exact frame captured on entry.\n\n```bash\nlats call solo.enter\nlats call solo.status\nlats call solo.exit\n```\n\nEach method returns the resulting `active` state. Mutating methods also return\n`ok`; `solo.enter` returns `ok: false` when there is no eligible frontmost\nwindow.\n\n#### `stage.status`, `stage.enter`, `stage.exit`, `stage.toggle`, `stage.cycle`\n\nStage is on or off. The windows on the display you are using decide the grid:\none window takes the stage, and more windows fill the grid around it. A window\nthat appears while Stage is on joins the grid. The window you are in grows to\n40% of the display width and comes forward. The others stay in their cells,\nslightly smaller. Clicking another window in the set, or `stage.cycle`, moves\nthe stage. Escape and `stage.exit` put every window back where it was when it\njoined. Hyper+X toggles it. Control-Option-Tab moves through the set while it\nis on.\n\n```bash\nlats call stage.enter\nlats call stage.cycle '{\"by\":-1}'\nlats call stage.exit\n```\n\n`stage.enter` turns Stage on whatever the window count is. `stage.cycle` takes `by` (default `1`).\n\n#### `daemon.status`\n\nHealth check and basic stats.\n\n**Params**: none\n\n**Returns**:\n\n```json\n{\n  \"uptime\": 3600.5,\n  \"clientCount\": 2,\n  \"version\": \"1.0.0\",\n  \"windowCount\": 12,\n  \"tmuxSessionCount\": 3,\n  \"frontmostWid\": 1234,\n  \"permissions\": {\n    \"accessibility\": true,\n    \"screenRecording\": true\n  }\n}\n```\n\n#### `api.schema`\n\nReturn the full API schema including version, models, and method definitions.\nUseful for agent self-discovery.\n\n**Params**: none\n\nCLI shortcut:\n\n```bash\nlats call api.schema\n```\n\n#### `diagnostics.list`\n\nReturn recent diagnostic log entries from the daemon.\n\n**Params**:\n\n| Field   | Type   | Required | Description                    |\n|---------|--------|----------|--------------------------------|\n| `limit` | number | no       | Max entries to return (default 50) |\n\n---\n\n## Mouse & Input\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `mouse.find` | read | Show a sonar pulse at the current cursor |\n| `mouse.summon` | write | Move the cursor to a point or screen center |\n| `mouse.shortcuts.get` | read | Return the live mouse shortcut config |\n| `mouse.shortcuts.reload` | write | Reload `~/.lattices/mouse-shortcuts.json` without restarting |\n| `mouse.shortcuts.set` | write | Replace the full mouse shortcut config and activate it |\n| `mouse.shortcuts.upsert` | write | Create or replace one mouse shortcut rule and activate it |\n| `mouse.shortcuts.remove` | write | Remove one mouse shortcut rule and activate the new config |\n| `mouse.shortcuts.restoreDefaults` | write | Restore default mouse shortcuts |\n\nMouse shortcut rules are data. Prefer `shortcut.send` for hotkeys an agent can\ndefine or change directly; do not add a named action unless the behavior cannot\nbe expressed as data.\n\nCreate or replace a gesture that sends Hyper+D:\n\n```js\nawait daemonCall('mouse.shortcuts.upsert', {\n  rule: {\n    id: 'middle-up-voice',\n    enabled: true,\n    device: 'any',\n    trigger: { button: 'middle', kind: 'drag', direction: 'up' },\n    action: {\n      type: 'shortcut.send',\n      shortcut: {\n        key: 'd',\n        keyCode: 2,\n        modifiers: ['control', 'option', 'shift', 'command']\n      }\n    }\n  }\n})\n```\n\nIf an agent edits `~/.lattices/mouse-shortcuts.json` itself, refresh the running\napp explicitly:\n\n```js\nawait daemonCall('mouse.shortcuts.reload')\n```\n\nAll write methods persist the config, checkpoint the previous version in\n`~/.lattices/mouse-shortcuts.history/`, and update the active event-tap snapshot\nimmediately. No app restart is required.\n\nSupported action types:\n\n| Type | Purpose |\n|------|---------|\n| `shortcut.send` | Send a data-defined key or keyCode with modifiers |\n| `app.activate` | Activate an app by name |\n| `space.previous` | Switch to the previous macOS Space |\n| `space.next` | Switch to the next macOS Space |\n| `screenmap.toggle` | Open the Screen Map overview |\n| `dictation.start` | Legacy alias that presses the configured Voice Command hotkey |\n\n---\n\n## Windows & Spaces\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `desktop.snapshot` | read | One-call current desktop: frontmost, layer, displays, windows, sessions, permissions |\n| `windows.list` | read | All visible windows |\n| `windows.get` | read | Single window by ID |\n| `windows.preview` | read | Cached PNG preview for a window |\n| `windows.search` | read | Search windows by query |\n| `spaces.list` | read | macOS display spaces |\n| `windows.pick` | read | Ask the user to choose one visible window in a read-only Hyperspace survey |\n| `windows.place` | write | Place a window or session using a typed placement spec |\n| `windows.focus` | write | Focus a window / switch Spaces |\n| `windows.move` | write | Move a window to another display, placement slot, or Space |\n| `spaces.optimize` | write | Optimize a set of windows using an explicit scope and strategy |\n| `layout.distribute` | write | Compatibility wrapper for visible-window balancing |\n\n#### `desktop.snapshot`\n\nOne call for “what is in front of the user?” Structured state — not a drawing.\n`lats map` is the ASCII render of this same current-space view (it now\nreads `desktop.snapshot` when the app is new enough).\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `includeOffscreen` | boolean | no | Include off-screen windows. Default `false`. |\n\n**Returns**:\n\n```json\n{\n  \"frontmost\": { \"wid\": 1234, \"app\": \"iTerm2\", \"title\": \"…\", \"pid\": 1 },\n  \"activeLayer\": { \"id\": \"web\", \"label\": \"Web\", \"index\": 0, \"slot\": 1, \"active\": true, \"layout\": null, \"entries\": [] },\n  \"displays\": [ { \"displayIndex\": 0, \"name\": \"Built-in\", \"frame\": {}, \"visibleFrame\": {}, \"currentSpaceId\": 1, \"spaces\": [] } ],\n  \"currentSpaceId\": 1,\n  \"windows\": [\n    {\n      \"wid\": 1234,\n      \"app\": \"iTerm2\",\n      \"title\": \"…\",\n      \"pid\": 5678,\n      \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n      \"spaceIds\": [1],\n      \"isOnScreen\": true,\n      \"zIndex\": 0,\n      \"focused\": true,\n      \"latticesSession\": \"lattices-a1b2c3\"\n    }\n  ],\n  \"sessions\": [ { \"name\": \"lattices-a1b2c3\", \"attached\": true, \"windowCount\": 1 } ],\n  \"permissions\": { \"accessibility\": true, \"screenRecording\": true }\n}\n```\n\nWindow frames are top-left global, same as `windows.list`. Does not recapture\nwindows or run OCR.\n\n```bash\nlats call desktop.snapshot\nlats map\n```\n\n#### `windows.list`\n\nList all visible windows tracked by the desktop model.\n\n**Params**: none\n\n**Returns**: array of window objects:\n\n```json\n[\n  {\n    \"wid\": 1234,\n    \"app\": \"Terminal\",\n    \"pid\": 5678,\n    \"title\": \"[lattices:myapp-a1b2c3] zsh\",\n    \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"spaceIds\": [1],\n    \"isOnScreen\": true,\n    \"latticesSession\": \"myapp-a1b2c3\"\n  }\n]\n```\n\nThe `latticesSession` field is present only on windows that belong to\na lattices session (matched via the `[lattices:name]` title tag).\n\n#### `windows.get`\n\nGet a single window by its CGWindowID.\n\n**Params**:\n\n| Field | Type   | Required | Description       |\n|-------|--------|----------|-------------------|\n| `wid` | number | yes      | CGWindowID        |\n\n**Returns**: a single window object (same shape as `windows.list` items).\n**Errors**: `Not found` if the window ID doesn't exist.\n\n#### `windows.search`\n\nSearch windows by text query across title, app name, session tags, and OCR content. Returns results with `matchSource` indicating how the match was found, and `ocrSnippet` for OCR matches.\n\n**Params**:\n\n| Field   | Type   | Required | Description                    |\n|---------|--------|----------|--------------------------------|\n| `query` | string | yes      | Search query (matches title, app, session, OCR text) |\n| `ocr`   | boolean| no       | Include OCR text in search (default true) |\n| `limit` | number | no       | Max results (default 50)       |\n\n**Returns**: array of window objects with additional search fields:\n\n```json\n[\n  {\n    \"wid\": 265,\n    \"app\": \"iTerm2\",\n    \"title\": \"✳ Claude Code\",\n    \"matchSource\": \"ocr\",\n    \"ocrSnippet\": \"…~/dev/vox StatusBarIconFolder…\",\n    \"frame\": { \"x\": 688, \"y\": 3, \"w\": 1720, \"h\": 720 },\n    \"isOnScreen\": true\n  }\n]\n```\n\n`matchSource` values: `\"title\"`, `\"app\"`, `\"session\"`, `\"ocr\"`.\n\n**CLI usage**:\n\n```bash\n# Basic search (uses windows.search)\nlats search vox\n\n# Deep search — adds terminal tab/process inspection for ranking\nlats search vox --deep\n\n# Same as --deep (all search sources)\n# A TTY matched only at app level (see terminals.list) adds 1 point to each\n# window of its terminal app, with the source \"terminal-app\". `lats place`\n# does not act on a result that has only that source.\nlats search vox --all\n\n# Pipeable output\nlats search vox --wid\nlats search vox --json\n\n# Search + focus + tile in one step\nlats place vox right\n```\n\n#### `windows.preview`\n\nReturn a cached PNG preview for a tracked window. When the cache is cold,\n`load` starts an asynchronous warmup and the caller can poll until `ok` is true.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | number | yes | CGWindowID from `windows.list` |\n| `load` | boolean | no | Warm the cache when missing; defaults to `true` |\n\nThe response includes `cached` and `loading`. A ready preview also includes\n`mimeType: \"image/png\"`, base64 image data, and pixel dimensions.\n\n#### `spaces.list`\n\nList macOS display spaces (virtual desktops).\n\nDisplay `frame` and `visibleFrame` use the same top-left global coordinate\nsystem as window frames returned by `windows.list`.\nSee [Workspace Map](/docs/workspace-map) for the complete coordinate contract,\nterminal/JSON projection, and visible-surface lifecycle guidance.\n\n**Params**: none\n\n**Returns**: array of display objects:\n\n```json\n[\n  {\n    \"displayIndex\": 0,\n    \"displayId\": \"main\",\n    \"name\": \"Built-in Liquid Retina XDR Display\",\n    \"frame\": { \"x\": 0, \"y\": 0, \"w\": 1728, \"h\": 1117 },\n    \"visibleFrame\": { \"x\": 0, \"y\": 38, \"w\": 1728, \"h\": 1079 },\n    \"currentSpaceId\": 1,\n    \"spaces\": [\n      { \"id\": 1, \"index\": 1, \"name\": \"Desktop 1\", \"display\": 0, \"isCurrent\": true },\n      { \"id\": 2, \"index\": 2, \"name\": \"Desktop 2\", \"display\": 0, \"isCurrent\": false }\n    ]\n  }\n]\n```\n\n#### `windows.place`\n\nCanonical window placement mutation. Use this when an agent needs a\nsingle, typed placement contract across voice, CLI, and daemon clients.\n\n**Params**:\n\n| Field       | Type            | Required | Description                              |\n|-------------|-----------------|----------|------------------------------------------|\n| `wid`       | number          | no       | Target window ID                          |\n| `session`   | string          | no       | Target lattices session                   |\n| `app`       | string          | no       | Target app name                           |\n| `title`     | string          | no       | Optional title substring for app matching |\n| `display`   | number          | no       | Target display index                      |\n| `placement` | string \\| object | yes     | Placement shorthand or typed object       |\n| `dryRun`    | bool            | no       | Plan and verify inputs without moving     |\n\nTarget resolution priority is `wid` → `session` → `app/title` → frontmost window.\n\n**Placement strings**: `left`, `right`, `top`, `bottom`, `top-left`, `top-right`,\n`bottom-left`, `bottom-right`, `left-third`, `center-third`, `right-third`,\n`top-third`, `middle-third`, `bottom-third`, `left-quarter`, `right-quarter`,\n`top-quarter`, `bottom-quarter`, `maximize`, `center`, `grid:CxR:C,R`, or\ncompact `CxR:C,R`. The canonical `grid:` form is 0-indexed; the compact form is\n1-indexed for command entry.\n\n**Typed placement examples**:\n\n```json\n{ \"kind\": \"tile\", \"value\": \"top-right\" }\n{ \"kind\": \"grid\", \"columns\": 3, \"rows\": 2, \"column\": 2, \"row\": 0 }\n{ \"kind\": \"fractions\", \"x\": 0.5, \"y\": 0, \"w\": 0.5, \"h\": 1 }\n```\n\n**Returns**: execution receipt including resolved target, placement, and trace.\n\n#### `windows.pick`\n\nOpen Hyperspace in a read-only selection mode and wait for the user to choose\none visible window. Letter hints, Tab/Return, and clicking a survey tile select;\nEscape cancels. Placement, layer editing, and normal motion commands are disabled\nfor the duration of the pick.\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `client` | string | no | Calling client label |\n| `requestId` | string | no | Caller correlation id |\n| `prompt` | string | no | Prompt shown in the selection HUD |\n| `mode` | string | no | `single-window` (default and currently supported mode) |\n| `currentWid` | number | no | Visible window to preselect |\n| `timeoutMs` | number | no | Wait time, clamped to 5–300 seconds; default 120 seconds |\n\nReturns `status: \"selected\"` with a window, or `cancelled`, `timeout`, or\n`unavailable` without one.\n\n#### `windows.focus`\n\nFocus a window — bring it to front and switch Spaces if needed.\n\n**Params** (one of):\n\n| Field     | Type   | Required | Description                     |\n|-----------|--------|----------|---------------------------------|\n| `wid`     | number | no       | CGWindowID (any window)         |\n| `session` | string | no       | Session name (lats windows)  |\n\nProvide either `wid` or `session`. If `wid` is given, it takes priority.\n\n#### `windows.move`\n\nMove a specific window to a display and desktop in one verified operation.\nWithout a placement, the window keeps its normalized position and size on the\ntarget display. A Space ID identifies one desktop on one display; Desktop 2\non a physical monitor differs from Desktop 2 on a virtual display.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `wid` | number | no | Target CGWindowID (preferred) |\n| `session` | string | no | Session name (legacy target) |\n| `display` | number | no | Target display index from `spaces.list` |\n| `spaceId` | number | no | Absolute target Space ID from `spaces.list` |\n| `placement` | string \\| object | no | Placement slot on the target display |\n| `position` | string | no | Alias for `placement` |\n| `dryRun` | bool | no | Validate and plan without moving the window or changing the view |\n\nProvide `wid` or `session`, plus at least one of `display`, `spaceId`, or\n`placement`. There is no frontmost-window fallback.\n\n- `spaceId` selects its owning display when `display` is omitted. If both are\n  supplied, the Space must belong to that display. A mismatch is an error.\n- `display` alone selects that display's current desktop. Display indexes\n  resolve through stable display UUIDs.\n- `placement` can accompany either destination. Without a placement, a\n  cross-display move maps the source frame onto the target visible frame and\n  clamps it to fit. A same-display desktop move preserves the frame.\n- Unknown displays, Spaces, and placements are errors. A window must belong\n  to one ordinary desktop; full-screen and multi-Space windows are rejected\n  when their relocation cannot be verified.\n\nFor an inactive source desktop, Lattices first carries the window to the\nsource display's current desktop. It then moves the window to the target\ndisplay and carries it to the requested desktop. Mission Control can appear\nduring these steps. Keep the pointer free until the operation finishes.\n\n**Returns**: an execution receipt with `status`\n(`ok`/`planned`/`blocked`/`failed`), before/target/after frames in `mutations`,\n`sourceDisplay`, `display`, `spaceId`, `fromSpaceIds`, and `afterSpaceIds`.\n`verified` requires `verifiedFrame`, `verifiedDisplay`, and `verifiedSpace`\nto succeed. An AX call or a desktop view switch alone cannot report success.\nA placement receipt retains `action.type: \"window.place\"` and\n`compatibilityMethod: \"window.move\"`.\n\nOn partial failure, Lattices attempts to restore the original display,\ndesktop, and frame. The receipt reports `failureReason`, `rollback`, and the\nobserved final state. Successful moves support `actions.undo`, including the\noriginal desktop. Allow up to 60 seconds for verification and recovery.\n\n```js\n// Move to the current desktop on display 0, preserving relative geometry.\nawait daemonCall('window.move', { wid: 4182, display: 0 }, 60000)\n\n// Choose the exact physical display and desktop in one operation.\nawait daemonCall('window.move', { wid: 4182, display: 0, spaceId: 3 }, 60000)\n\n// Preview a move and placement without changing the window or desktop view.\nawait daemonCall('window.move', {\n  wid: 4182, display: 0, spaceId: 3, placement: 'left', dryRun: true\n})\n```\n\n#### `spaces.optimize`\n\nCanonical space-balancing mutation. Use this when the goal is to make\nthe current workspace coherent rather than placing one specific window.\n\n**Params**:\n\n| Field       | Type     | Required | Description                                         |\n|-------------|----------|----------|-----------------------------------------------------|\n| `scope`     | string   | no       | `visible`, `active-app`, `app`, or `selection`      |\n| `strategy`  | string   | no       | `balanced` or `mosaic`                              |\n| `app`       | string   | no       | App name for `app` scope                            |\n| `title`     | string   | no       | Optional title substring for app matching           |\n| `windowIds` | number[] | no       | Explicit window IDs for `selection` scope           |\n\nIf `windowIds` is provided, scope is inferred as `selection`.\nIf `app` is provided and `scope` is omitted, scope is inferred as `app`.\n\n**Returns**: execution receipt including resolved scope, strategy,\naffected window IDs, and trace.\n\n#### `layout.distribute`\n\nCompatibility wrapper for `spaces.optimize` with `scope=visible` and\n`strategy=balanced`.\n\n**Params**: none\n\n---\n\n## Sessions\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `tmux.list` | read | Lattices tmux sessions, or every session including orphans |\n| `sessions.launch` | write | Launch a project session |\n| `sessions.kill` | write | Kill a session |\n| `sessions.detach` | write | Detach clients from a session |\n| `sessions.sync` | write | Reconcile session to config |\n| `sessions.restart` | write | Restart a pane's process |\n\nAll session methods require tmux to be installed.\n\n#### `tmux.list`\n\nList tmux sessions that belong to lattices.\n\n**Params**:\n\n| Field            | Type    | Required | Description |\n|------------------|---------|----------|-------------|\n| `includeOrphans` | boolean | no       | Return every tmux session, including ones lattices does not track |\n\n**Returns**: array of session objects:\n\n```json\n[\n  {\n    \"name\": \"myapp-a1b2c3\",\n    \"windowCount\": 1,\n    \"attached\": true,\n    \"panes\": [\n      {\n        \"id\": \"%0\",\n        \"windowIndex\": 0,\n        \"windowName\": \"main\",\n        \"title\": \"claude\",\n        \"currentCommand\": \"claude\",\n        \"pid\": 9876,\n        \"isActive\": true\n      }\n    ]\n  }\n]\n```\n\nWith `includeOrphans: true`, returns every tmux session instead, including\norphans (sessions not tracked by lattices):\n\n```json\n{\n  \"all\": [ ... ],\n  \"orphans\": [ ... ]\n}\n```\n\nBoth arrays contain session objects (same shape as `tmux.list`).\n\n#### `sessions.launch`\n\nLaunch a new tmux session for a project. If a session already exists,\nit will be reattached. The project must be in the scanned project list —\ncall `projects.list` to check, or `projects.scan` to refresh.\n\n**Params**:\n\n| Field  | Type   | Required | Description                      |\n|--------|--------|----------|----------------------------------|\n| `path` | string | yes      | Absolute path to project directory |\n\n**Returns**: a receipt, not a queued ok:\n\n```json\n{\n  \"ok\": true,\n  \"session\": \"myapp-a1b2c3\",\n  \"alreadyRunning\": false,\n  \"wid\": 1234,\n  \"verified\": true\n}\n```\n\n`ok` / `verified` are false if no tagged window and no tmux session appeared\nwithin ~4s. **Errors**: `Not found` if the path isn't in the scanned project\nlist.\n\n#### `sessions.kill`\n\nKill a tmux session by name.\n\n**Params**:\n\n| Field  | Type   | Required | Description         |\n|--------|--------|----------|---------------------|\n| `name` | string | yes      | Session name        |\n\n**Returns**: `{ \"ok\": true, \"session\": \"…\", \"verified\": true }` when the\nsession is gone. `ok` / `verified` are false if it is still listed.\n\n#### `sessions.detach`\n\nDetach all clients from a session (keeps it running).\n\n**Params**:\n\n| Field  | Type   | Required | Description         |\n|--------|--------|----------|---------------------|\n| `name` | string | yes      | Session name        |\n\n#### `sessions.sync`\n\nReconcile a running session to match its declared `.lattices.json` config.\nRecreates missing panes, re-applies layout, restores labels, re-runs\ncommands in idle panes.\n\n**Params**:\n\n| Field  | Type   | Required | Description                      |\n|--------|--------|----------|----------------------------------|\n| `path` | string | yes      | Absolute path to project directory |\n\n**Errors**: `Not found` if the path isn't in the project list.\n\n#### `sessions.restart`\n\nRestart a specific pane's process within a session.\n\n**Params**:\n\n| Field  | Type   | Required | Description                      |\n|--------|--------|----------|----------------------------------|\n| `path` | string | yes      | Absolute path to project directory |\n| `pane` | string | no       | Pane name to restart (defaults to first pane) |\n\n---\n\n## Projects & Layers\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `projects.list` | read | Discovered projects |\n| `projects.scan` | write | Re-scan project directory |\n| `layers.list` | read | Workspace layers, active index, and what switches put away |\n| `layers.members` | read | Each layer's entries and the windows they hold, on any desktop |\n| `layers.exit` | write | Exit layer focus and restore parked windows |\n| `layers.reveal` | write | Compatibility alias for `layers.exit` |\n| `layers.create` | write | Save windows as a new layer |\n| `layers.assign` | write | Add windows to a layer |\n| `layers.unassign` | write | Take windows out of a layer |\n| `layers.rename` | write | Rename a layer |\n| `layers.delete` | write | Delete a layer |\n| `layers.activate` | write | Switch to a workspace layer; `focus`, `tile` or `launch` |\n| `layers.switch` | write | Same as `layers.activate` |\n| `groups.launch` | write | Launch a tab group |\n| `groups.kill` | write | Kill a tab group |\n| `tabs.list` | read | List ephemeral cross-app tab stacks |\n| `tabs.stack` | write | Create a stack from selected or explicit windows |\n| `tabs.add` | write | Add windows to a live stack |\n| `tabs.select` | write | Focus a member tab |\n| `tabs.layout` | write | Toggle or set tabs/grid layout |\n| `tabs.unstack` | write | Ungroup without closing windows |\n\n#### `projects.list`\n\nList all discovered projects.\n\n**Params**: none\n\n**Returns**: array of project objects:\n\n```json\n[\n  {\n    \"path\": \"/Users/you/dev/myapp\",\n    \"name\": \"myapp\",\n    \"sessionName\": \"myapp-a1b2c3\",\n    \"isRunning\": true,\n    \"hasConfig\": true,\n    \"paneCount\": 2,\n    \"paneNames\": [\"claude\", \"server\"],\n    \"devCommand\": \"pnpm dev\",\n    \"packageManager\": \"pnpm\"\n  }\n]\n```\n\n`devCommand` and `packageManager` are present only when detected.\n\n#### `projects.scan`\n\nTrigger a re-scan of the project directory. Useful after cloning a new\nrepo or adding a `.lattices.json` config.\n\n**Params**: none\n\n#### `layers.list`\n\nList configured workspace layers and whether layer focus is active.\n\n**Params**: none\n\n**Returns**:\n\n```json\n{\n  \"layers\": [\n    { \"id\": \"web\", \"label\": \"Web\", \"index\": 0, \"projectCount\": 2 },\n    { \"id\": \"mobile\", \"label\": \"Mobile\", \"index\": 1, \"projectCount\": 2 }\n  ],\n  \"active\": 0,\n  \"focusActive\": true,\n  \"rememberedIndex\": 0,\n  \"stage\": { \"parked\": [], \"hidden\": [] }\n}\n```\n\n`active` is `null` after exiting layers. `rememberedIndex` retains the last\nselected layer index; it does not mean that layer focus is active. `stage`\nlists windows parked and apps hidden by Lattices. Returns an empty `layers`\narray if no workspace config is loaded.\n\n#### `layers.exit`\n\nExit layer focus and restore windows that Lattices parked or hid. Saved\nlayer definitions and window desktop assignments remain intact. Apps the\nuser hid stay hidden. Select a layer explicitly to enter layer focus again.\n\n**Params**: none\n\n**Returns**:\n\n```json\n{\n  \"ok\": true,\n  \"focusActive\": false,\n  \"active\": null,\n  \"unparked\": 3,\n  \"stillParked\": 0,\n  \"rescued\": 0,\n  \"unhidden\": [\"Slack\"],\n  \"stillHidden\": [],\n  \"recoveryPending\": false,\n  \"persistenceWarning\": null,\n  \"stage\": { \"parked\": [], \"hidden\": [] }\n}\n```\n\n`unparked` counts restored windows. `rescued` counts untracked windows\nrecovered from the parking corner. `stillParked` and `stillHidden` report\nrestoration that remains pending; `ok` confirms exit, not complete recovery.\n\n`recoveryPending` remains true while window restoration or saving the recovery\nstate is pending. `persistenceWarning` is a string when saving fails, or `null`\nwhen saving succeeds. Restoration can finish before the state is saved, so\n`recoveryPending` can be true when `stillParked` is zero and `stillHidden` is\nempty. Lattices retains the recovery state and retries saving it.\n\nA window on an inactive desktop can remain parked until that desktop is\nvisible. Lattices retains its saved position and retries restoration when\nyou visit the desktop. The exit action does not switch desktops or gather\nwindows onto the current desktop. Check `recoveryPending` and `stage` for\noutstanding recovery.\n\n#### `layers.reveal`\n\nCompatibility alias for `layers.exit`, with the same result. The former\nShow All action also exits layer focus. Lattices centres untracked windows\nfound in the parking corner on the main screen and counts them in `rescued`.\n\n#### `layers.members`\n\nEach layer's entries and the windows each one holds, on every desktop,\nfrom the same resolution a layer switch uses. A window belongs to one\nlayer. `presence` says where it is: `showing` (on a desktop a display is\nshowing), `elsewhere` (another desktop, or full screen), `parked` (in the\ncorner a switch parks windows in) or `hidden` (its app is hidden).\n`missing` says why an entry holds nothing: `No window` when its app runs,\n`Not open` when it doesn't. Moves nothing.\n\n**Params**:\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n| `layer` | string | no | Layer id or label (default: every layer) |\n| `index` | int | no | Layer index |\n\n**Returns**:\n\n```json\n{\n  \"active\": 0,\n  \"layers\": [\n    {\n      \"id\": \"web\", \"label\": \"Web\", \"index\": 0, \"slot\": 1, \"active\": true, \"layout\": \"auto\",\n      \"entries\": [\n        {\n          \"index\": 0, \"name\": \"Ghostty\", \"rule\": \"App contains Ghostty · Name: web\", \"pattern\": \"web\",\n          \"missing\": null,\n          \"windows\": [\n            { \"wid\": 4812, \"app\": \"Ghostty\", \"title\": \"mini: web · server\", \"presence\": \"parked\", \"where\": \"Parked\" }\n          ]\n        },\n        { \"index\": 1, \"name\": \"Figma\", \"rule\": \"App contains Figma\", \"pattern\": null, \"missing\": \"Not open\", \"windows\": [] }\n      ]\n    }\n  ]\n}\n```\n\n`desktop.snapshot` reports the active layer in the same shape as\n`activeLayer`.\n\n#### Editing layers\n\n`layers.create`, `layers.assign`, `layers.unassign`, `layers.rename` and\n`layers.delete` write the `layers` in `~/.lattices/workspace.json`, the\nsame ones ⌘⌥ switches. The first write each launch copies the file to\n`workspace.json.bak`. A window is saved as an entry of its app and its\ntitle; while the window lives it stays in the layer when its title changes.\n\nEach takes the layer as `layer` (id or label) or `index`; `assign` and\n`unassign` default to the active layer, and take `wid` or `windowIds`.\n\n| Method | Params | Returns |\n|--------|--------|---------|\n| `layers.create` | `name`; `windowIds`, or `windows: [{wid, tile?}]`, or `visible` (the default: every window on screen) | `{index, id, label, count}` |\n| `layers.assign` | `wid` / `windowIds` | `{index, added}` |\n| `layers.unassign` | `wid` / `windowIds` | `{index, removed, held}`: `held` lists windows an entry matching other windows too (a bare app, a project) still holds |\n| `layers.rename` | `name` | `ok` |\n| `layers.delete` | | `ok` |\n\n```js\nawait daemonCall('layers.create', { name: 'review', windows: [{ wid: 1234, tile: 'left' }, { wid: 5678 }] })\nawait daemonCall('layers.assign', { layer: 'review', wid: 4321 })\nawait daemonCall('layers.unassign', { layer: 'review', wid: 4321 })\n```\n\n#### `layers.activate`\n\nSwitch to a workspace layer. With no `mode` it switches as ⌘⌥ does.\n\n**Params**:\n\n| Field   | Type   | Required | Description                                 |\n|---------|--------|----------|---------------------------------------------|\n| `index` | number | no       | Layer index (0-based)                       |\n| `name`  | string | no       | Layer ID or label                           |\n| `mode`  | string | no       | `focus` (default), `tile`, or `launch`      |\n\nProvide either `index` or `name`.\n\n**Modes**:\n\n- `focus` — put away what the layer doesn't use, raise its windows and\n  apply its `layout`: the ⌘⌥ switch\n- `tile` — focus, then move entries with a `tile` to their place\n  (`retile` is accepted too)\n- `launch` — start the projects that aren't running, then tile\n\n**Returns**: execution receipt including resolved layer, mode, and trace.\n\n#### `layers.switch`\n\nThe same as `layers.activate`, `focus` unless a `mode` is given. It\nposts a `layer.switched` event.\n\n#### `groups.launch`\n\nLaunch a tab group session.\n\n**Params**:\n\n| Field | Type   | Required | Description      |\n|-------|--------|----------|------------------|\n| `id`  | string | yes      | Group ID         |\n\n**Errors**: `Not found` if the group ID doesn't match any configured group.\n\n#### `groups.kill`\n\nKill a tab group session.\n\n**Params**:\n\n| Field | Type   | Required | Description      |\n|-------|--------|----------|------------------|\n| `id`  | string | yes      | Group ID         |\n\n#### Live tab stacks\n\nLive stacks operate on windows that are already open and last for the current\nLattices run. If `windowIds` is omitted, create/add uses the latest multi-window\nselection from Hyperspace.\n\n```bash\nlats call tabs.stack '{\"name\":\"Research\",\"placement\":\"top-left\"}'\nlats call tabs.layout '{\"mode\":\"grid\"}'\nlats call tabs.select '{\"index\":1}'\nlats call tabs.layout '{\"mode\":\"tabs\"}'\nlats call tabs.unstack '{}'\n```\n\n`tabs.stack` needs at least two live windows. You may instead pass\n`{\"windowIds\":[123,456]}`. The optional `id` on add/select/layout/delete defaults\nto the active group. Layout mode is `tabs`, `grid`, or `toggle`; selecting uses a\nzero-based `index` or `windowId`.\n\n---\n\n## Processes & Terminals\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `processes.list` | read | Running developer processes |\n| `processes.tree` | read | Process tree from a PID |\n| `terminals.list` | read | Terminal instances with processes |\n| `terminals.search` | read | Search terminals by criteria |\n\n#### `processes.list`\n\nList running processes relevant to development (editors, servers, build tools).\n\n**Params**:\n\n| Field     | Type   | Required | Description                        |\n|-----------|--------|----------|------------------------------------|\n| `command` | string | no       | Filter by command name substring   |\n\n**Returns**: array of process objects:\n\n```json\n[\n  {\n    \"pid\": 1234,\n    \"ppid\": 567,\n    \"command\": \"node\",\n    \"args\": \"server.js\",\n    \"cwd\": \"/Users/you/dev/myapp\",\n    \"tty\": \"/dev/ttys003\",\n    \"tmuxSession\": \"myapp-a1b2c3\",\n    \"tmuxPaneId\": \"%0\"\n  }\n]\n```\n\n#### `processes.tree`\n\nGet the process tree rooted at a given PID.\n\n**Params**:\n\n| Field | Type   | Required | Description   |\n|-------|--------|----------|---------------|\n| `pid` | number | yes      | Root PID      |\n\n**Returns**: array of process objects (same shape as `processes.list`).\n\n#### `terminals.capture`\n\nExact tmux pane text. Not OCR, not a screenshot.\n\nProvide one of `session`, `paneId`, or `tty`. If a session has more than one\npane, also pass `pane` or `paneId`.\n\n**Params**:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `session` | string | one of session / paneId / tty | tmux or lattices session name |\n| `pane` | string | no | Pane title or `%id` |\n| `paneId` | string | no | tmux pane id (`%12`) |\n| `tty` | string | no | `/dev/ttys003` or `ttys003` |\n| `lines` | number | no | Last N lines. Default 80, max 500 |\n| `escape` | boolean | no | Keep ANSI. Default `false` (`tmux capture-pane -p`) |\n\n**Returns**:\n\n```json\n{\n  \"ok\": true,\n  \"session\": \"lattices-a1b2c3\",\n  \"paneId\": \"%3\",\n  \"paneName\": \"claude\",\n  \"tty\": \"/dev/ttys012\",\n  \"text\": \"…last N lines…\",\n  \"lineCount\": 80\n}\n```\n\nAmbiguous targets return an error listing candidates. Missing tmux returns a\nclear error. Never falls back to pixels.\n\n```bash\nlats call terminals.capture '{\"session\":\"lattices-c36f74\",\"lines\":40}'\n```\n\n#### `terminals.list`\n\nList all discovered terminal instances with their processes, tabs, and tmux associations.\n\n**Params**:\n\n| Field     | Type    | Required | Description                          |\n|-----------|---------|----------|--------------------------------------|\n| `refresh` | boolean | no       | Explicitly refresh terminal-tab metadata through terminal app scripting |\n\n**Returns**: array of terminal instance objects:\n\n```json\n[\n  {\n    \"tty\": \"/dev/ttys003\",\n    \"app\": \"Terminal\",\n    \"windowIndex\": 0,\n    \"tabIndex\": 0,\n    \"isActiveTab\": true,\n    \"tabTitle\": \"myapp\",\n    \"processes\": [ ... ],\n    \"shellPid\": 1234,\n    \"cwd\": \"/Users/you/dev/myapp\",\n    \"tmuxSession\": \"myapp-a1b2c3\",\n    \"tmuxPaneId\": \"%0\",\n    \"hasClaude\": true,\n    \"displayName\": \"Terminal — myapp\",\n    \"windowId\": 4211,\n    \"windowAttribution\": \"terminal\"\n  }\n]\n```\n\nTerminal coverage. With `refresh: true`, Lattices reads tab data from these\nterminal apps, and only from apps that are already running:\n\n| Terminal | Tab data | Window match | What the terminal needs |\n|----------|----------|--------------|-------------------------|\n| iTerm2 | AppleScript | Window ID | Automation access |\n| Terminal.app | AppleScript | Window ID | Automation access |\n| Ghostty 1.3+ | AppleScript (tab title, terminal ID, cwd) | Window title, when one window has it | Automation access that you already granted. Lattices does not ask for it. |\n| kitty | `kitty @ ls` | Window ID | `allow_remote_control` and `listen_on` in `kitty.conf` |\n| Warp, Alacritty, others | None | Process tree only | Nothing |\n\nGhostty reports no TTY, so Lattices joins each Ghostty terminal to a TTY by\nits working directory. It skips a terminal when two shells share that\ndirectory. When tab data is not available, Lattices finds the terminal\nprocess that owns the TTY. It picks that process's window when the process\nhas one titled window, or when exactly one window title contains the shell's\ncwd. Otherwise the TTY has no `windowId`. It has `windowAttribution: \"app\"`,\nand `windowCandidates` lists the windows of that process, frontmost first.\n\n`windowAttribution` values: `tag` (the window title has the `[lattices:…]`\ntag), `terminal` (the terminal reported the window ID), `terminal-title`,\n`position`, `cwd-title`, `single-window`, `app`.\n\n#### `terminals.search`\n\nSearch terminal instances by various criteria.\n\n**Params**:\n\n| Field      | Type    | Required | Description                          |\n|------------|---------|----------|--------------------------------------|\n| `command`  | string  | no       | Filter by command name substring     |\n| `cwd`      | string  | no       | Filter by working directory substring |\n| `app`      | string  | no       | Filter by terminal app name          |\n| `session`  | string  | no       | Filter by tmux session name          |\n| `hasClaude`| boolean | no       | Filter to only Claude-running TTYs   |\n\n**Returns**: filtered array of terminal instance objects (same shape as `terminals.list`).\n\n---\n\n## OCR\n\n| Method | Type | Description |\n|--------|------|-------------|\n| `ocr.snapshot` | read | Current OCR results for all visible windows |\n| `ocr.search` | read | Full-text search across OCR history |\n| `ocr.history` | read | OCR timeline for one window, or all windows |\n| `ocr.scan` | write | Trigger an immediate OCR scan |\n\nSee [Screen OCR](/docs/ocr) for configuration, scan schedules, and storage details.\n\n#### `ocr.snapshot`\n\nGet the current in-memory OCR results for all visible windows.\n\n**Params**: none\n\n**Returns**: array of OCR result objects:\n\n```json\n[\n  {\n    \"wid\": 1234,\n    \"app\": \"Terminal\",\n    \"title\": \"zsh\",\n    \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"fullText\": \"~/dev/myapp $ npm run dev\\nready on port 3000\",\n    \"blocks\": [\n      {\n        \"text\": \"~/dev/myapp $ npm run dev\",\n        \"confidence\": 0.95,\n        \"x\": 0.02, \"y\": 0.05, \"w\": 0.6, \"h\": 0.04\n      }\n    ],\n    \"timestamp\": 1709568000.0\n  }\n]\n```\n\n#### `ocr.search`\n\nFull-text search across OCR history using SQLite FTS5.\n\n**Params**:\n\n| Field   | Type    | Required | Description                              |\n|---------|---------|----------|------------------------------------------|\n| `query` | string  | yes      | FTS5 search query                        |\n| `app`   | string  | no       | Filter by application name               |\n| `limit` | number  | no       | Max results (default 50)                 |\n| `live`  | boolean | no       | Search live snapshot instead of history (default false) |\n\n**FTS5 query examples**: `error`, `\"build failed\"`, `error OR warning`, `npm AND dev`, `react*`\n\n#### `ocr.history`\n\nGet the OCR timeline for a specific window, or across every window when\n`wid` is omitted, ordered by most recent first.\n\n**Params**:\n\n| Field   | Type   | Required | Description                |\n|---------|--------|----------|----------------------------|\n| `wid`   | number | no       | CGWindowID; omit for every window |\n| `limit` | number | no       | Max results (default 50)   |\n\n#### `ocr.scan`\n\nTrigger an immediate OCR scan of all visible windows, bypassing the\nperiodic timer. Results available via `ocr.snapshot` once complete;\nan `ocr.scanComplete` event is broadcast to all clients.\n\n**Params**: none\n\n---\n\n## Events\n\nEvents are pushed to all connected WebSocket clients when state changes.\nThey have no `id` field — listen for messages with an `event` field.\n\n| Event | Trigger |\n|-------|---------|\n| `windows.changed` | Desktop window list changes |\n| `tmux.changed` | Sessions created, killed, or modified |\n| `layer.switched` | Active workspace layer changes |\n| `ocr.scanComplete` | OCR scan cycle finishes |\n| `processes.changed` | Developer processes start or stop |\n\nA connection receives every event until it filters them. `events.subscribe`\ntakes `{ \"events\": [\"windows.changed\"] }` (or `[\"*\"]` for all again);\n`events.unsubscribe` removes names, or every event when called without a\nlist. Both act on the connection they arrive on and return its current\nfilter. Linux hosts (LAT-013) push `windows.changed` and `spaces.changed`.\n\n#### `windows.changed`\n\n```json\n{ \"event\": \"windows.changed\", \"data\": { \"windowCount\": 12, \"added\": [1234], \"removed\": [5678] } }\n```\n\n#### `tmux.changed`\n\n```json\n{ \"event\": \"tmux.changed\", \"data\": { \"sessionCount\": 3, \"sessions\": [\"myapp-a1b2c3\"] } }\n```\n\n#### `layer.switched`\n\n```json\n{ \"event\": \"layer.switched\", \"data\": { \"index\": 1, \"name\": \"mobile\" } }\n```\n\n#### `ocr.scanComplete`\n\n```json\n{ \"event\": \"ocr.scanComplete\", \"data\": { \"windowCount\": 12, \"totalBlocks\": 342 } }\n```\n\n#### `processes.changed`\n\n```json\n{ \"event\": \"processes.changed\", \"data\": { \"interestingCount\": 5, \"pids\": [1234, 5678] } }\n```\n\n---\n\n## Agent integration\n\n### CLAUDE.md snippet\n\nAdd this to your project's `CLAUDE.md` so any AI agent working in the\nproject knows how to control the workspace:\n\n```markdown\n## Workspace Control\n\nThis project uses lattices for workspace management. The daemon API\nuses authenticated WebSocket over ~/.lattices/run/lattices.sock.\nUse a CLI or daemonCall client that matches the app version for discovery and authentication.\n\n### Search (find windows)\n- Search by content: `daemonCall('windows.search', { query: 'myproject' })`\n  Returns windows with `matchSource` (\"title\", \"app\", \"session\", \"ocr\") and `ocrSnippet`\n- Search terminals: `daemonCall('terminals.search', {})` — tabs, cwds, processes\n- CLI: `lats search myproject`, `lats search myproject --deep`, or `lats search myproject --all` (same as `--deep`)\n\n### Actions\n- Focus a window: `daemonCall('windows.focus', { wid: 1234 })`\n- Place a window: `daemonCall('windows.place', { session: 'name', placement: 'left' })`\n- Launch a project: `daemonCall('sessions.launch', { path: '/absolute/path' })`\n- Switch layers: `daemonCall('layers.activate', { name: 'web' })`; add `mode: 'launch'` to start what isn't running\n- Optimize the workspace: `daemonCall('spaces.optimize', { scope: 'visible', strategy: 'balanced' })`\n- CLI: `lats place myproject left` (search + focus + tile in one step)\n\n### Import\n\\```js\nimport { daemonCall } from '@lattices/lattices'\n\\```\n```\n\n### Multi-agent orchestration\n\nAn orchestrator agent can set up the full workspace for sub-agents:\n\n```js\nimport { daemonCall } from '@lattices/lattices'\n\n// Discover what's available\nconst projects = await daemonCall('projects.list')\n\n// Launch the projects we need\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/frontend' })\nawait daemonCall('sessions.launch', { path: '/Users/you/dev/api' })\n\n// Tile them side by side\nconst sessions = await daemonCall('tmux.list')\nconst fe = sessions.find(s => s.name.startsWith('frontend'))\nconst api = sessions.find(s => s.name.startsWith('api'))\n\nawait daemonCall('windows.place', { session: fe.name, placement: 'left' })\nawait daemonCall('windows.place', { session: api.name, placement: 'right' })\n```\n\n### Reactive event pattern\n\nSubscribe to events to react to workspace changes:\n\n```js\nimport WebSocket from 'ws'\nimport { readFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n\nconst runtime = process.env.LATTICES_RUNTIME_DIR || join(homedir(), '.lattices/run')\nconst token = readFileSync(join(runtime, 'lattices.token'), 'utf8').trim()\nconst ws = new WebSocket('ws://localhost', {\n  socketPath: join(runtime, 'lattices.sock'),\n  headers: { Host: 'localhost', 'x-lattices-token': token },\n})\n\nws.on('message', (raw) => {\n  const msg = JSON.parse(raw)\n\n  if (msg.event === 'tmux.changed') {\n    console.log('Sessions:', msg.data.sessions.join(', '))\n  }\n\n  if (msg.event === 'windows.changed') {\n    console.log('Windows:', msg.data.windowCount, 'total')\n  }\n\n  if (msg.event === 'layer.switched') {\n    console.log('Switched to layer', msg.data.index)\n  }\n})\n\n// You can also send RPC calls on the same connection\nws.on('open', () => {\n  ws.send(JSON.stringify({ id: '1', method: 'daemon.status' }))\n})\n```\n\n### Health check before use\n\nAlways verify the daemon is running before making calls:\n\n```js\nimport { isDaemonRunning, daemonCall } from '@lattices/lattices'\n\nif (!(await isDaemonRunning())) {\n  console.error('lats daemon is not running — start it with: lats app')\n  process.exit(1)\n}\n\nconst status = await daemonCall('daemon.status')\nconsole.log(`Daemon up for ${Math.round(status.uptime)}s, tracking ${status.windowCount} windows`)\n```\n\n### Pi extension\n\nPi users can install the `@arach/pi-lattices` package in\n`packages/pi-lattices/` to expose the daemon as typed `lattices_*` tools:\n\n```bash\npi install ./packages/pi-lattices --local\nlats app\n```\n\nThe extension wraps the existing daemon and keeps Lattices' macOS-native\nruntime, run artifacts, action receipts, and computer-use `treatment` semantics.\nIt does not bundle `cua-driver` or enable browser automation. See\n[Pi Lattices Extension](/docs/pi-lattices) for the tool list and smoke checks.\n\n### Machines and visiting cursor\n\nThe Machines page uses these existing endpoints: `visit.status`, `visit.pair`,\n`visit.arm`, `visit.end`, `visit.screens`, and `visit.elsewhere`.\n\n| Method | Parameters | Result |\n| --- | --- | --- |\n| `visit.forget` | `host: string` | `{ok: boolean}`; false if absent. Ends an active visit to that host. |\n| `visit.side` | `host: string`, `side: left\\|right\\|top\\|bottom` | `{ok: true}`; errors for unknown hosts or invalid sides. Occupied sides swap, preserving both keys. |\n| `mouse.home` | none | Ends the current visit and returns the cursor to this Mac. |\n\n`visit.pair` requires host-side approval of the returned code. A new pairing\ncannot replace an occupied side; move or forget its occupant first.\n\n## Machines and displays\n\n`lats @<host> <command...>` routes daemon-backed CLI commands through `callHost`\nusing `~/.lattices/hosts.json` and `LATTICES_HOSTS`. `@local` selects the local\nendpoint. No SSH is used. Commands requiring local files, application lifecycle\nor tmux actions report `local-only`; remote RPC failures never fall back to local\nactions. `lats @host call <method> [params-json]` exposes the host's API directly.\nA host only supports methods present in its installed version.\n\n`lats machines [--json]` merges configured hosts, outbound visit pairings and\nthis machine by name/address. It probes `host.describe` and reports reachability,\nOS, version/commit, display count, pairing and placement. Offline/unknown values\nremain explicit. Pairing inventory falls back to the saved visit host list when\nthe local daemon is unavailable. JSON returns `{machines, pairingError?}`.\n\n| Method | Parameters | Behavior |\n| --- | --- | --- |\n| `host.describe` | none | Mac/Linux identity: `platform`, `hostname`, `version`, `build` (version/commit), `displays`, capabilities and methods. Unknown build fields are null. |\n| `bring` | `display: int|string`, or `undo: true` | Composes `display.gather` for every other display. Undo delegates to `display.restore`. Returns gather or restore results. |\n| `main` | `display: int|string`, `keep?: bool` | Delegates to `visit.main`; starts its 15-second rollback trial. `keep` calls `visit.arrangement.keep` only after success. |\n| `elsewhere` | `display: int|string`, `name?: string` | Alias of `visit.elsewhere` with `on: true`; optionally records the machine on the display. |\n| `here` | `display: int|string` | Alias of `visit.elsewhere` with `on: false`. |\n| `visit.start` | `host: string` | Starts an explicit visit at the midpoint of a paired machine's touching span, without arming edge crossing. Missing pairing, no touching span, overlap or capture failure is an error. End the current visit before starting another. |\n| `home` | none | Alias of `mouse.home`: ends the visit and returns the cursor home. |\n\nCLI forms: `bring dell`, `bring --undo`, `main u32 --keep`,\n`elsewhere u32 archie`, `here u32`, `visit archie`, and `home`.\nDisplay selectors use the `display.gather` resolver: display index from\n`display list`, or a case-insensitive name fragment (first match). The existing\n`visit.main` and `visit.elsewhere` `screen` parameters accept these same selectors;\nCLI `--display` flags and `capture display` also accept names.\n\nThe Machines page, command bar and Machines menu use these same operations.\nThe page shows version/commit and marks a lower numeric release version **Behind**.\nA differing commit without proven version ordering is **Different build**, not a\nclaim about commit ancestry. The Machines page's Open action selects the existing\nstill/live host view. No deployment, pairing, arming or visit occurs on page open."
    },
    {
      "id": "lattices:remote-streaming",
      "slug": "remote-streaming",
      "product": "lattices",
      "kind": "reference",
      "catalogKind": "reference",
      "current": true,
      "title": "Remote stream acknowledgments",
      "description": "This note describes current source builds. It does not announce a published release.",
      "order": null,
      "sourcePath": "docs/remote-streaming.md",
      "url": "/lattices/docs/remote-streaming",
      "rawUrl": "/lattices/docs/markdown/remote-streaming.md",
      "markdownUrl": "/lattices/docs/remote-streaming.md",
      "headings": [
        {
          "depth": 1,
          "text": "Remote stream acknowledgments",
          "anchor": "remote-stream-acknowledgments"
        }
      ],
      "tokensEstimate": 197,
      "frontmatter": {},
      "markdown": "# Remote stream acknowledgments\n\nThis note describes current source builds. It does not announce a published release.\n\nFor an active `capture.stream.start` session, the viewer sends\n`capture.stream.ack {streamId, seq}` on the connection that started the stream.\nEach acknowledgment covers all frames through `seq`. Send an acknowledgment\nwhen each frame arrives.\n\nLinux hosts allow `max(4, ceil(fps × 0.2))` frames to remain unacknowledged:\nsix at 30 fps and twelve at 60 fps. This capacity lets a 60 fps stream continue\nwhen acknowledgment round trips exceed the 67 ms ceiling of a four-frame window.\nThe pending queue remains bounded by 200 ms of age, 200 ms of frames, and 4 MiB.\nThese are queue limits, not a guarantee of end-to-end latency.\n\nMac hosts retain a four-frame window and pause input to the encoder at that\nlimit. Both hosts end the stream after roughly three seconds without\nacknowledgment progress while frames remain outstanding.\n",
      "content": "# Remote stream acknowledgments\n\nThis note describes current source builds. It does not announce a published release.\n\nFor an active `capture.stream.start` session, the viewer sends\n`capture.stream.ack {streamId, seq}` on the connection that started the stream.\nEach acknowledgment covers all frames through `seq`. Send an acknowledgment\nwhen each frame arrives.\n\nLinux hosts allow `max(4, ceil(fps × 0.2))` frames to remain unacknowledged:\nsix at 30 fps and twelve at 60 fps. This capacity lets a 60 fps stream continue\nwhen acknowledgment round trips exceed the 67 ms ceiling of a four-frame window.\nThe pending queue remains bounded by 200 ms of age, 200 ms of frames, and 4 MiB.\nThese are queue limits, not a guarantee of end-to-end latency.\n\nMac hosts retain a four-frame window and pause input to the encoder at that\nlimit. Both hosts end the stream after roughly three seconds without\nacknowledgment progress while frames remain outstanding."
    },
    {
      "id": "lattices:mcp",
      "slug": "mcp",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "MCP",
      "description": "The lats mcp server, its browser, hosts and windows toolsets, and how agent harnesses register it",
      "order": 8,
      "sourcePath": "docs/mcp.md",
      "url": "/lattices/docs/mcp",
      "rawUrl": "/lattices/docs/markdown/mcp.md",
      "markdownUrl": "/lattices/docs/mcp.md",
      "headings": [
        {
          "depth": 2,
          "text": "Current toolsets",
          "anchor": "current-toolsets"
        },
        {
          "depth": 3,
          "text": "The windows toolset",
          "anchor": "the-windows-toolset"
        },
        {
          "depth": 2,
          "text": "Why a subcommand and not a plugin path",
          "anchor": "why-a-subcommand-and-not-a-plugin-path"
        },
        {
          "depth": 3,
          "text": "The runtime still has to be found",
          "anchor": "the-runtime-still-has-to-be-found"
        },
        {
          "depth": 3,
          "text": "Alternatives considered",
          "anchor": "alternatives-considered"
        },
        {
          "depth": 2,
          "text": "Layout",
          "anchor": "layout"
        },
        {
          "depth": 2,
          "text": "Toolsets",
          "anchor": "toolsets"
        },
        {
          "depth": 3,
          "text": "Tool names are not namespaced",
          "anchor": "tool-names-are-not-namespaced"
        },
        {
          "depth": 2,
          "text": "Lifecycle guarantees",
          "anchor": "lifecycle-guarantees"
        },
        {
          "depth": 2,
          "text": "What happened to the plugin and the action marketplace",
          "anchor": "what-happened-to-the-plugin-and-the-action-marketplace"
        },
        {
          "depth": 2,
          "text": "Migrating a machine",
          "anchor": "migrating-a-machine"
        },
        {
          "depth": 2,
          "text": "Verifying",
          "anchor": "verifying"
        }
      ],
      "tokensEstimate": 2428,
      "frontmatter": {
        "title": "MCP",
        "description": "The lats mcp server, its browser, hosts and windows toolsets, and how agent harnesses register it",
        "order": 8
      },
      "markdown": "---\ntitle: MCP\ndescription: The lats mcp server, its browser, hosts and windows toolsets, and how agent harnesses register it\norder: 8\n---\n\nOne MCP server ships with the `lattices` package. Agent config names the binary,\nnever a path:\n\n```json\n{ \"command\": \"lattices\", \"args\": [\"mcp\"] }\n```\n\nLattices **0.15.2** includes all 29 tools below: 14 browser tools, nine host\ntools, and six window tools. Install the matching app and CLI, then restart\nthe MCP connection in your agent harness after upgrading.\n\n## Current toolsets\n\n| Toolset | Tools | Does the work | Product docs |\n|---------|-------|---------------|--------------|\n| `browser` | `browser_open`, `browser_tabs`, `browser_snapshot`, `browser_click`, `browser_fill`, `browser_resize`, `browser_screenshot`, `browser_console`, `browser_close`, `browser_profiles`, `browser_use_profile`, `browser_profile_info`, `browser_import_cookies`, `browser_companion_status` | Action Browser code inside the `lattices` package, driving Action-owned Chrome over CDP | [Action browser](/action/docs/browser) |\n| `hosts` | `hosts_list`, `host_describe`, `host_windows`, `host_screenshot`, `host_read`, `host_place`, `host_focus`, `host_act`, `host_call` | This Mac's daemon over its private socket, or a paired host through its gateway | [Agent API](/lattices/docs/api) |\n| `windows` | `windows_search`, `windows_place`, `windows_focus`, `layers_list`, `layers_switch`, `history_undo` | This Mac's daemon over its private socket | [Agent API](/lattices/docs/api) |\n\nThe `hosts` and `windows` toolsets reach this Mac's daemon the way `lats`\ndoes: over the Unix socket `~/.lattices/run/lattices.sock`, with the token in\n`~/.lattices/run/lattices.token`. No TCP port is open by default. A paired\nremote host is reached through its gateway, which binds the host's Tailscale\naddress (port 9399 by default) and accepts only signed, approved clients. See\n[Agent API](/lattices/docs/api).\n\n### The `windows` toolset\n\nThe same window controls as the CLI, as tools on this Mac. Each tool wraps\none daemon method and reuses the CLI's search and place code:\n\n| Tool | Daemon method | Same as |\n|------|---------------|---------|\n| `windows_search` | `search.query` (`sources: [\"all\"]` with `deep: true`) | `lats search`, `lats search --deep` |\n| `windows_place` | `windows.place`; by `query`, deep search + `windows.focus` + `windows.place` | `lats window place`, `lats place` |\n| `windows_focus` | `windows.focus` | `lats focus` |\n| `layers_list` | `layers.list` | `lats layer` |\n| `layers_switch` | `layers.activate` | `lats layer <name or slot>` |\n| `history_undo` | `history.undo` | `lats call history.undo` |\n\nSearch with the basic sources first. Set `deep: true` only when the basic\nsearch misses, for example a project that appears only in a terminal's cwd.\n`windows_place` returns the daemon receipt with its `receiptId` at the top\nlevel; pass that id to `history_undo` to put the window back.\n\n`lats mcp` is a Lattices entry point. The `browser` toolset is Action Browser\ncode, so its workflow and profile rules belong to Action. Action.app also has\nits own MCP server for private layers and native observe and act, started with\n`lats action mcp`. That server is separate from `lats mcp`. See\n[Action browser](/action/docs/browser) for which one to use.\n\n## Why a subcommand and not a plugin path\n\nBefore this, Action Browser was addressed as if it were an independent product\nliving at a fixed place on disk. Every harness held its own copy of that\nassumption, and each one broke differently:\n\n| Harness | What it pointed at | Failure mode |\n|---|---|---|\n| Claude Code | marketplace `action` → `installLocation: /Users/art/dev/action` | Directory moved into `lattices/products/action`. The tools were simply **absent**: no error, nothing in `/mcp`. |\n| Claude Code (after the first fix) | `.../plugins/cache/action/action-browser/0.3.0/` | A version-pinned copy. The next version bump orphans it. |\n| Codex | `.../products/action/plugins/action-browser/scripts/...` | A checkout path. The next repo move breaks it. |\n\nEach of those is the same bug wearing a different hat: **the address of the tools\nwas a location instead of a name.**\n\nA subcommand fixes it because resolution follows the installed binary. If\n`lattices` is on `PATH`, `lattices mcp` is correct, whether that is a global\n`bun install -g @lattices/lattices`, a `bun link` from a checkout, or a future\nHomebrew formula. Moving the repo, bumping the version, and reinstalling all\nbecome no-ops for agent config.\n\n### The runtime still has to be found\n\nNaming the binary moves the problem from \"where is the code\" to \"is the binary on\nthis process's `PATH`\" -- which on macOS is not free. A GUI-launched app inherits\nthe bare system `PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), and neither `lattices`\nnor `bun` is there:\n\n```console\n$ env -i PATH=\"/usr/bin:/bin:/usr/sbin:/sbin\" sh -c 'command -v lattices || echo missing'\nmissing\n```\n\nThe retired `run-action-browser-mcp.sh` knew this -- it hunted for Bun across\n`ACTION_BUN_BIN`, `PATH`, and the usual install locations, and its test was\nliterally named \"launcher finds the user Bun with a GUI PATH\". Deleting that\nscript without replacing the behaviour would have traded one silent \"the browser\ntools are gone\" for another.\n\nSo `bin/lattices` is a POSIX shell launcher, not a bun-shebanged script. It\nresolves through however many symlinks the install put in the way, finds Bun\n(`LATTICES_BUN_BIN` → `PATH` → `~/.bun/bin`, Homebrew, `/usr/local`), and execs\n`bin/lattices.ts`. A harness that can find `lattices` at all can now run it, and a\nmachine with no Bun gets a one-line error instead of a failed exec.\n\nFinding `lattices` itself is the part a launcher cannot solve. Claude Code\nresolves it (verified: `claude mcp list` reports the server connected). A harness\nthat spawns with a bare system `PATH` would not, and the fallback there is to name\nthe install location -- `/Users/<you>/.bun/bin/lattices` -- which is still stable\nacross repo moves and version bumps, and so is still strictly better than what it\nreplaced.\n\n### Alternatives considered\n\n- **Point the marketplace at `github: lattices-dev/lattices` instead of a local\n  directory.** Fixes today's specific breakage, and is genuinely path-free from\n  the user's side. Rejected as the primary mechanism because it is Claude\n  Code–only: Codex and Kimi would still hold literal paths, and each plugin\n  update still writes a fresh version-pinned cache directory.\n- **A separate `lattices-mcp` bin entry.** Works, but adds a second published\n  binary name to keep in step with the first for no gain over a subcommand.\n- **Publishing the MCP as its own npm package.** Reintroduces the thing being\n  removed: two products to install, two versions to keep aligned.\n\n## Layout\n\n```\nbin/lattices             # POSIX shell launcher: finds Bun, execs the CLI\nbin/lattices.ts          # the CLI; `mcp` dispatches into bin/mcp\nbin/mcp/\n  server.ts              # `lattices mcp` entry: flags, signals, process\n  router.ts              # stdio JSON-RPC, tool -> toolset table\n  registry.ts            # lazily loaded toolsets\n  types.ts               # the Toolset contract\n  toolsets/\n    browser/             # Action Browser, moved here from products/action\n      index.ts\n      navigation.ts\n      viewport.ts\n      transport.ts\n      ...\n    hosts/               # Lattices hosts over the daemon protocol\n      index.ts\n    windows/             # this Mac's search, place, focus, layers, undo\n      index.ts\n```\n\nThe server lives under `bin/`, which the npm package includes. The package also\nincludes the two Action browser profile and cookie helpers it uses; it does\nnot require an Action checkout. Release checks start the server from the\nextracted package and verify its tool list and helper imports. The root `tsconfig.json`\nalready covers `bin/**/*.ts`, so the browser server is now typechecked by\n`bun run check:types`, which it was not before.\n\n## Toolsets\n\nThe router owns the protocol; a toolset owns its tools.\n\n```ts\ntype Toolset = {\n  name: string;\n  tools: ToolDefinition[];\n  init?(): Promise<void>;      // startup work, e.g. sweeping dead-owner claims\n  onToolCall?(): void;         // per-call bookkeeping, e.g. the idle timer\n  callTool(name, args): Promise<ToolResult>;\n  instructions?: string[];\n};\n```\n\n`init` runs once at startup for every enabled toolset. `onToolCall` fires only\nfor that toolset's own tools, so a call into one toolset does not reset another's\nidle timer.\n\n`lats mcp --toolsets browser` narrows to a subset; the default is all of\nthem. `lats mcp --list` prints the registry without speaking the protocol.\n\n### Tool names are not namespaced\n\n`browser_open`, `browser_screenshot`, `browser_close` and the rest keep their\nexact names. They are load-bearing: they appear in the operator's global\n`CLAUDE.md` and in standing instructions across three harnesses, and a rename\nsilently invalidates all of it.\n\nWhat changes is the *client-side prefix*, which is derived from the server name\nand was never stable anyway:\n\n```\nmcp__plugin_action-browser_action-browser__browser_open   →   mcp__lattices__browser_open\n```\n\nNo instruction anywhere refers to the prefixed form, so nothing needs aliasing.\nFuture toolsets follow the same convention: a short verb-ish prefix per domain\n(`browser_*`, `host_*`, `windows_*`, `layers_*`, and later `session_*`) rather than a product name.\n\n## Lifecycle guarantees\n\nThese are the reason Action Browser is written the way it is: orphaned headless\nChromes filled this machine's disk on 2026-07-22. The fold **moves** that code\nrather than reimplementing it, and deliberately changes none of its identifiers:\n\n- Ownership claim files in `~/Library/Application Support/Action/BrowserSessions`\n- Session names of the form `action-<pid>-<rand>` (`pidFromSessionName` parses\n  that prefix, so it is part of the on-disk contract, not cosmetic)\n- `ACTION_BROWSER_*` / `ACTION_CHROME_COMPANION_*` environment variables\n- CDP port `9334`, profile root `~/Library/Application Support/Action/ChromeProfiles`\n- Owner-dies → browser-dies, via `SIGTERM`/`SIGINT`/`SIGHUP` hooks plus a\n  synchronous `exit` release\n- The 15-minute idle close (`ACTION_BROWSER_IDLE_TIMEOUT_MS`)\n- Ref-counting across concurrent servers sharing profile + port\n- The startup sweep for dead-owner claims\n\nKeeping the claim schema byte-identical has a migration payoff: an old plugin\nserver still running from the plugin cache and a new `lattices mcp` server\nref-count *each other* correctly. The two can overlap during a rollout without\neither one killing a Chrome the other is using.\n\n## What happened to the plugin and the `action` marketplace\n\n**The plugin no longer ships an MCP server.** `.mcp.json`, the `mcpServers`\nblocks in the Codex and Kimi manifests, and `scripts/run-action-browser-mcp.sh`\nare gone. There is exactly one live path to the browser tools: `lattices mcp`.\n\nTwo registrations would have been worse than none. A session with both the\nplugin installed *and* `lattices mcp` configured gets `browser_open` twice under\ndifferent prefixes, and an agent picking between two identical tools backed by\ntwo separate Chrome-owning processes is a lifecycle problem, not a cosmetic one.\n\n**The plugin survives as a skill carrier.** It still ships\n`skills/action-browser/SKILL.md`, and the marketplace is renamed `action` →\n`lattices` to match where the code now lives. The split is deliberate:\n\n- The load-bearing part (tools) is addressed by name and cannot be broken by\n  moving the repo.\n- The nice-to-have part (skill prose) is still installed the plugin way, and if\n  *that* ever breaks the failure is an agent with slightly less guidance, not an\n  agent with no browser.\n\nThat asymmetry is the whole design. The 2026-09-10 outage was bad specifically\nbecause a path failure took out capability. Now a path failure can only take out\ndocumentation.\n\n## Migrating a machine\n\nClaude Code:\n\n```bash\nclaude plugin uninstall action-browser@action\nclaude plugin marketplace remove action\nclaude mcp add lattices -s user -- lattices mcp\n# optional, for the skill only:\nclaude plugin marketplace add lattices-dev/lattices\nclaude plugin install action-browser@lattices --scope user\n```\n\nCodex: replace the `[mcp_servers.action-browser]` block in `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.lattices]\ncommand = \"lattices\"\nargs = [\"mcp\"]\n```\n\nKimi:\n\n```bash\nrm -rf ~/.kimi-code/plugins/managed/action-browser\n```\n\nthen add the same `command: \"lattices\", args: [\"mcp\"]` entry to its MCP config.\n\n`lattices mcp --print-config <claude|codex|kimi>` emits these snippets so a new\nmachine does not have to be told twice.\n\n## Verifying\n\n```bash\nlattices mcp --list                    # toolsets and tool names, no protocol\nbun test bin/mcp                       # router + toolset contract, each toolset\n```\n\nIn a harness: `/mcp` should show `lattices` connected, and `browser_open`\nfollowed by `browser_screenshot` should return a PNG.\n",
      "content": "One MCP server ships with the `lattices` package. Agent config names the binary,\nnever a path:\n\n```json\n{ \"command\": \"lattices\", \"args\": [\"mcp\"] }\n```\n\nLattices **0.15.2** includes all 29 tools below: 14 browser tools, nine host\ntools, and six window tools. Install the matching app and CLI, then restart\nthe MCP connection in your agent harness after upgrading.\n\n## Current toolsets\n\n| Toolset | Tools | Does the work | Product docs |\n|---------|-------|---------------|--------------|\n| `browser` | `browser_open`, `browser_tabs`, `browser_snapshot`, `browser_click`, `browser_fill`, `browser_resize`, `browser_screenshot`, `browser_console`, `browser_close`, `browser_profiles`, `browser_use_profile`, `browser_profile_info`, `browser_import_cookies`, `browser_companion_status` | Action Browser code inside the `lattices` package, driving Action-owned Chrome over CDP | [Action browser](/action/docs/browser) |\n| `hosts` | `hosts_list`, `host_describe`, `host_windows`, `host_screenshot`, `host_read`, `host_place`, `host_focus`, `host_act`, `host_call` | This Mac's daemon over its private socket, or a paired host through its gateway | [Agent API](/lattices/docs/api) |\n| `windows` | `windows_search`, `windows_place`, `windows_focus`, `layers_list`, `layers_switch`, `history_undo` | This Mac's daemon over its private socket | [Agent API](/lattices/docs/api) |\n\nThe `hosts` and `windows` toolsets reach this Mac's daemon the way `lats`\ndoes: over the Unix socket `~/.lattices/run/lattices.sock`, with the token in\n`~/.lattices/run/lattices.token`. No TCP port is open by default. A paired\nremote host is reached through its gateway, which binds the host's Tailscale\naddress (port 9399 by default) and accepts only signed, approved clients. See\n[Agent API](/lattices/docs/api).\n\n### The `windows` toolset\n\nThe same window controls as the CLI, as tools on this Mac. Each tool wraps\none daemon method and reuses the CLI's search and place code:\n\n| Tool | Daemon method | Same as |\n|------|---------------|---------|\n| `windows_search` | `search.query` (`sources: [\"all\"]` with `deep: true`) | `lats search`, `lats search --deep` |\n| `windows_place` | `windows.place`; by `query`, deep search + `windows.focus` + `windows.place` | `lats window place`, `lats place` |\n| `windows_focus` | `windows.focus` | `lats focus` |\n| `layers_list` | `layers.list` | `lats layer` |\n| `layers_switch` | `layers.activate` | `lats layer <name or slot>` |\n| `history_undo` | `history.undo` | `lats call history.undo` |\n\nSearch with the basic sources first. Set `deep: true` only when the basic\nsearch misses, for example a project that appears only in a terminal's cwd.\n`windows_place` returns the daemon receipt with its `receiptId` at the top\nlevel; pass that id to `history_undo` to put the window back.\n\n`lats mcp` is a Lattices entry point. The `browser` toolset is Action Browser\ncode, so its workflow and profile rules belong to Action. Action.app also has\nits own MCP server for private layers and native observe and act, started with\n`lats action mcp`. That server is separate from `lats mcp`. See\n[Action browser](/action/docs/browser) for which one to use.\n\n## Why a subcommand and not a plugin path\n\nBefore this, Action Browser was addressed as if it were an independent product\nliving at a fixed place on disk. Every harness held its own copy of that\nassumption, and each one broke differently:\n\n| Harness | What it pointed at | Failure mode |\n|---|---|---|\n| Claude Code | marketplace `action` → `installLocation: /Users/art/dev/action` | Directory moved into `lattices/products/action`. The tools were simply **absent**: no error, nothing in `/mcp`. |\n| Claude Code (after the first fix) | `.../plugins/cache/action/action-browser/0.3.0/` | A version-pinned copy. The next version bump orphans it. |\n| Codex | `.../products/action/plugins/action-browser/scripts/...` | A checkout path. The next repo move breaks it. |\n\nEach of those is the same bug wearing a different hat: **the address of the tools\nwas a location instead of a name.**\n\nA subcommand fixes it because resolution follows the installed binary. If\n`lattices` is on `PATH`, `lattices mcp` is correct, whether that is a global\n`bun install -g @lattices/lattices`, a `bun link` from a checkout, or a future\nHomebrew formula. Moving the repo, bumping the version, and reinstalling all\nbecome no-ops for agent config.\n\n### The runtime still has to be found\n\nNaming the binary moves the problem from \"where is the code\" to \"is the binary on\nthis process's `PATH`\" -- which on macOS is not free. A GUI-launched app inherits\nthe bare system `PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), and neither `lattices`\nnor `bun` is there:\n\n```console\n$ env -i PATH=\"/usr/bin:/bin:/usr/sbin:/sbin\" sh -c 'command -v lattices || echo missing'\nmissing\n```\n\nThe retired `run-action-browser-mcp.sh` knew this -- it hunted for Bun across\n`ACTION_BUN_BIN`, `PATH`, and the usual install locations, and its test was\nliterally named \"launcher finds the user Bun with a GUI PATH\". Deleting that\nscript without replacing the behaviour would have traded one silent \"the browser\ntools are gone\" for another.\n\nSo `bin/lattices` is a POSIX shell launcher, not a bun-shebanged script. It\nresolves through however many symlinks the install put in the way, finds Bun\n(`LATTICES_BUN_BIN` → `PATH` → `~/.bun/bin`, Homebrew, `/usr/local`), and execs\n`bin/lattices.ts`. A harness that can find `lattices` at all can now run it, and a\nmachine with no Bun gets a one-line error instead of a failed exec.\n\nFinding `lattices` itself is the part a launcher cannot solve. Claude Code\nresolves it (verified: `claude mcp list` reports the server connected). A harness\nthat spawns with a bare system `PATH` would not, and the fallback there is to name\nthe install location -- `/Users/<you>/.bun/bin/lattices` -- which is still stable\nacross repo moves and version bumps, and so is still strictly better than what it\nreplaced.\n\n### Alternatives considered\n\n- **Point the marketplace at `github: lattices-dev/lattices` instead of a local\n  directory.** Fixes today's specific breakage, and is genuinely path-free from\n  the user's side. Rejected as the primary mechanism because it is Claude\n  Code–only: Codex and Kimi would still hold literal paths, and each plugin\n  update still writes a fresh version-pinned cache directory.\n- **A separate `lattices-mcp` bin entry.** Works, but adds a second published\n  binary name to keep in step with the first for no gain over a subcommand.\n- **Publishing the MCP as its own npm package.** Reintroduces the thing being\n  removed: two products to install, two versions to keep aligned.\n\n## Layout\n\n```\nbin/lattices             # POSIX shell launcher: finds Bun, execs the CLI\nbin/lattices.ts          # the CLI; `mcp` dispatches into bin/mcp\nbin/mcp/\n  server.ts              # `lattices mcp` entry: flags, signals, process\n  router.ts              # stdio JSON-RPC, tool -> toolset table\n  registry.ts            # lazily loaded toolsets\n  types.ts               # the Toolset contract\n  toolsets/\n    browser/             # Action Browser, moved here from products/action\n      index.ts\n      navigation.ts\n      viewport.ts\n      transport.ts\n      ...\n    hosts/               # Lattices hosts over the daemon protocol\n      index.ts\n    windows/             # this Mac's search, place, focus, layers, undo\n      index.ts\n```\n\nThe server lives under `bin/`, which the npm package includes. The package also\nincludes the two Action browser profile and cookie helpers it uses; it does\nnot require an Action checkout. Release checks start the server from the\nextracted package and verify its tool list and helper imports. The root `tsconfig.json`\nalready covers `bin/**/*.ts`, so the browser server is now typechecked by\n`bun run check:types`, which it was not before.\n\n## Toolsets\n\nThe router owns the protocol; a toolset owns its tools.\n\n```ts\ntype Toolset = {\n  name: string;\n  tools: ToolDefinition[];\n  init?(): Promise<void>;      // startup work, e.g. sweeping dead-owner claims\n  onToolCall?(): void;         // per-call bookkeeping, e.g. the idle timer\n  callTool(name, args): Promise<ToolResult>;\n  instructions?: string[];\n};\n```\n\n`init` runs once at startup for every enabled toolset. `onToolCall` fires only\nfor that toolset's own tools, so a call into one toolset does not reset another's\nidle timer.\n\n`lats mcp --toolsets browser` narrows to a subset; the default is all of\nthem. `lats mcp --list` prints the registry without speaking the protocol.\n\n### Tool names are not namespaced\n\n`browser_open`, `browser_screenshot`, `browser_close` and the rest keep their\nexact names. They are load-bearing: they appear in the operator's global\n`CLAUDE.md` and in standing instructions across three harnesses, and a rename\nsilently invalidates all of it.\n\nWhat changes is the *client-side prefix*, which is derived from the server name\nand was never stable anyway:\n\n```\nmcp__plugin_action-browser_action-browser__browser_open   →   mcp__lattices__browser_open\n```\n\nNo instruction anywhere refers to the prefixed form, so nothing needs aliasing.\nFuture toolsets follow the same convention: a short verb-ish prefix per domain\n(`browser_*`, `host_*`, `windows_*`, `layers_*`, and later `session_*`) rather than a product name.\n\n## Lifecycle guarantees\n\nThese are the reason Action Browser is written the way it is: orphaned headless\nChromes filled this machine's disk on 2026-07-22. The fold **moves** that code\nrather than reimplementing it, and deliberately changes none of its identifiers:\n\n- Ownership claim files in `~/Library/Application Support/Action/BrowserSessions`\n- Session names of the form `action-<pid>-<rand>` (`pidFromSessionName` parses\n  that prefix, so it is part of the on-disk contract, not cosmetic)\n- `ACTION_BROWSER_*` / `ACTION_CHROME_COMPANION_*` environment variables\n- CDP port `9334`, profile root `~/Library/Application Support/Action/ChromeProfiles`\n- Owner-dies → browser-dies, via `SIGTERM`/`SIGINT`/`SIGHUP` hooks plus a\n  synchronous `exit` release\n- The 15-minute idle close (`ACTION_BROWSER_IDLE_TIMEOUT_MS`)\n- Ref-counting across concurrent servers sharing profile + port\n- The startup sweep for dead-owner claims\n\nKeeping the claim schema byte-identical has a migration payoff: an old plugin\nserver still running from the plugin cache and a new `lattices mcp` server\nref-count *each other* correctly. The two can overlap during a rollout without\neither one killing a Chrome the other is using.\n\n## What happened to the plugin and the `action` marketplace\n\n**The plugin no longer ships an MCP server.** `.mcp.json`, the `mcpServers`\nblocks in the Codex and Kimi manifests, and `scripts/run-action-browser-mcp.sh`\nare gone. There is exactly one live path to the browser tools: `lattices mcp`.\n\nTwo registrations would have been worse than none. A session with both the\nplugin installed *and* `lattices mcp` configured gets `browser_open` twice under\ndifferent prefixes, and an agent picking between two identical tools backed by\ntwo separate Chrome-owning processes is a lifecycle problem, not a cosmetic one.\n\n**The plugin survives as a skill carrier.** It still ships\n`skills/action-browser/SKILL.md`, and the marketplace is renamed `action` →\n`lattices` to match where the code now lives. The split is deliberate:\n\n- The load-bearing part (tools) is addressed by name and cannot be broken by\n  moving the repo.\n- The nice-to-have part (skill prose) is still installed the plugin way, and if\n  *that* ever breaks the failure is an agent with slightly less guidance, not an\n  agent with no browser.\n\nThat asymmetry is the whole design. The 2026-09-10 outage was bad specifically\nbecause a path failure took out capability. Now a path failure can only take out\ndocumentation.\n\n## Migrating a machine\n\nClaude Code:\n\n```bash\nclaude plugin uninstall action-browser@action\nclaude plugin marketplace remove action\nclaude mcp add lattices -s user -- lattices mcp\n# optional, for the skill only:\nclaude plugin marketplace add lattices-dev/lattices\nclaude plugin install action-browser@lattices --scope user\n```\n\nCodex: replace the `[mcp_servers.action-browser]` block in `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.lattices]\ncommand = \"lattices\"\nargs = [\"mcp\"]\n```\n\nKimi:\n\n```bash\nrm -rf ~/.kimi-code/plugins/managed/action-browser\n```\n\nthen add the same `command: \"lattices\", args: [\"mcp\"]` entry to its MCP config.\n\n`lattices mcp --print-config <claude|codex|kimi>` emits these snippets so a new\nmachine does not have to be told twice.\n\n## Verifying\n\n```bash\nlattices mcp --list                    # toolsets and tool names, no protocol\nbun test bin/mcp                       # router + toolset contract, each toolset\n```\n\nIn a harness: `/mcp` should show `lattices` connected, and `browser_open`\nfollowed by `browser_screenshot` should return a PNG."
    },
    {
      "id": "lattices:agent-layer-guide",
      "slug": "agent-layer-guide",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Agent Guide: Generating Layers",
      "description": "How to create and manage Lattices workspace layers programmatically. This guide is for AI agents (Claude Code, etc.) that want to generate layers from high-level user descriptions.",
      "order": null,
      "sourcePath": "docs/agent-layer-guide.md",
      "url": "/lattices/docs/agent-layer-guide",
      "rawUrl": "/lattices/docs/markdown/agent-layer-guide.md",
      "markdownUrl": "/lattices/docs/agent-layer-guide.md",
      "headings": [
        {
          "depth": 1,
          "text": "Agent Guide: Generating Layers",
          "anchor": "agent-guide-generating-layers"
        },
        {
          "depth": 2,
          "text": "Quick Reference",
          "anchor": "quick-reference"
        },
        {
          "depth": 2,
          "text": "How It Works",
          "anchor": "how-it-works"
        },
        {
          "depth": 2,
          "text": "Step-by-Step: Generating a Layer",
          "anchor": "step-by-step-generating-a-layer"
        },
        {
          "depth": 3,
          "text": "1. Discover what's available",
          "anchor": "1-discover-what-s-available"
        },
        {
          "depth": 3,
          "text": "2. Decide on a layout",
          "anchor": "2-decide-on-a-layout"
        },
        {
          "depth": 3,
          "text": "3. Create the layer",
          "anchor": "3-create-the-layer"
        },
        {
          "depth": 3,
          "text": "4. Switch between layers",
          "anchor": "4-switch-between-layers"
        },
        {
          "depth": 2,
          "text": "Daemon API (Advanced)",
          "anchor": "daemon-api-advanced"
        },
        {
          "depth": 2,
          "text": "Composing Layers from Intent",
          "anchor": "composing-layers-from-intent"
        },
        {
          "depth": 3,
          "text": "\"Make me a coding layer\"",
          "anchor": "make-me-a-coding-layer"
        },
        {
          "depth": 3,
          "text": "\"Set up a design layer\"",
          "anchor": "set-up-a-design-layer"
        },
        {
          "depth": 3,
          "text": "\"Create a writing layer\"",
          "anchor": "create-a-writing-layer"
        },
        {
          "depth": 3,
          "text": "\"Give me a communication layer\"",
          "anchor": "give-me-a-communication-layer"
        },
        {
          "depth": 3,
          "text": "\"Split my work into layers by project\"",
          "anchor": "split-my-work-into-layers-by-project"
        },
        {
          "depth": 2,
          "text": "App Grouping Heuristics",
          "anchor": "app-grouping-heuristics"
        },
        {
          "depth": 2,
          "text": "Tips",
          "anchor": "tips"
        }
      ],
      "tokensEstimate": 1499,
      "frontmatter": {},
      "markdown": "# Agent Guide: Generating Layers\n\nHow to create and manage Lattices workspace layers programmatically. This guide is for AI agents (Claude Code, etc.) that want to generate layers from high-level user descriptions.\n\n## Quick Reference\n\n```bash\n# See what's on screen\nlats windows --json\n\n# Create a layer with tiling\nlats layer create \"Design\" --json '[\n  {\"app\": \"Figma\", \"tile\": \"left\"},\n  {\"app\": \"Google Chrome\", \"title\": \"Tailwind\", \"tile\": \"right\"}\n]'\n\n# Snapshot current windows as a layer\nlats layer snap \"my-context\"\n\n# Add a window to the layer you're on, or to a named one\nlats layer add wid:1234\nlats layer add wid:1234 --to \"Design\"\nlats layer remove wid:1234 --from \"Design\"\n\n# List / switch / rename / delete\nlats layer\nlats layer \"Design\"\nlats layer rename \"Design\" \"Figma work\"\nlats layer delete \"Design\"\n```\n\n## How It Works\n\nLayers live in `~/.lattices/workspace.json`, and they're the ones on the ⌘⌥ pad: the first four layers are ⌘⌥1–4, the next four ⌘⌥6–9 (5 is the pad's centre), and any after that have no digit. The CLI, the daemon API, voice (\"save this layout as deploy\") and ⌘⌥T all write that file, and a change takes effect at once.\n\nEach window is saved as an entry of its app and its title at the time, which is how the layer finds it again after a restart. While the window stays open it's pinned in, so a title that changes later (a browser switching tabs) doesn't drop it. To make an entry broader, edit its `title` in `workspace.json` down to the part that stays put.\n\n## Step-by-Step: Generating a Layer\n\n### 1. Discover what's available\n\n```bash\nlats windows --json\n```\n\nReturns an array of window objects:\n```json\n[\n  {\n    \"wid\": 1234,\n    \"app\": \"iTerm2\",\n    \"title\": \"lattices — zsh\",\n    \"latticesSession\": \"lattices-abc123\",\n    \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"spaceIds\": [1]\n  },\n  {\n    \"wid\": 5678,\n    \"app\": \"Google Chrome\",\n    \"title\": \"GitHub - lattices-dev/lattices\",\n    \"frame\": { \"x\": 960, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"spaceIds\": [1]\n  }\n]\n```\n\nKey fields for matching:\n- `wid` — unique window ID (most precise)\n- `app` — application name\n- `title` — window title (use for disambiguation when multiple windows of same app)\n- `latticesSession` — tmux session name (for terminal windows)\n\n### 2. Decide on a layout\n\nPick tile positions based on how many windows and what makes sense:\n\n| Windows | Good layout | Tile values |\n|---------|-------------|-------------|\n| 2 | Side by side | `left`, `right` |\n| 2 | Stacked | `top`, `bottom` |\n| 3 | Main + sidebar | `left` (60%), `top-right`, `bottom-right` |\n| 3 | Columns | `left-third`, `center-third`, `right-third` |\n| 4 | Quadrants | `top-left`, `top-right`, `bottom-left`, `bottom-right` |\n| 1 | Focused | `maximize` or `center` |\n\nFull position reference:\n- **Halves**: `left`, `right`, `top`, `bottom`\n- **Quarters**: `top-left`, `top-right`, `bottom-left`, `bottom-right`\n- **Thirds**: `left-third`, `center-third`, `right-third`\n- **Sixths**: `top-left-third`, `top-center-third`, `top-right-third`, `bottom-left-third`, `bottom-center-third`, `bottom-right-third`\n- **Fourths**: `first-fourth`, `second-fourth`, `third-fourth`, `last-fourth`\n- **Special**: `maximize`, `center`\n- **Custom grid**: `grid:CxR:C,R` (e.g. `grid:5x3:2,1`)\n\n### 3. Create the layer\n\n**Option A: By window ID (most reliable)**\n```bash\nlats layer create \"Coding\" --json '[\n  {\"wid\": 1234, \"tile\": \"left\"},\n  {\"wid\": 5678, \"tile\": \"right\"}\n]'\n```\n\n**Option B: By app name** (picks the first open window that matches)\n```bash\nlats layer create \"Research\" --json '[\n  {\"app\": \"Google Chrome\", \"title\": \"docs\", \"tile\": \"left\"},\n  {\"app\": \"Notes\", \"tile\": \"right\"}\n]'\n```\n\n**Option C: Simple wid list (no tiling)**\n```bash\nlats layer create \"Focus\" wid:1234 wid:5678\n```\n\n**Option D: Snapshot everything visible** (also what `create` does with no windows named)\n```bash\nlats layer snap \"Current Context\"\n```\n\n### 4. Switch between layers\n\n```bash\nlats layer           # list layers\nlats layer \"Coding\"  # switch to \"Coding\"\nlats layer 1         # switch to the layer on ⌘⌥1\n```\n\nOr press ⌘⌥ and the layer's slot number.\n\n## Daemon API (Advanced)\n\nFor finer control, use raw daemon calls:\n\n```bash\n# Create a layer from window IDs (omit windowIds to save what's on screen)\nlats call layers.create '{\"name\":\"Coding\",\"windowIds\":[1234,5678]}'\n\n# Add windows to a layer (default: the active one)\nlats call layers.assign '{\"layer\":\"Coding\",\"windowIds\":[9012]}'\n\n# Take a window out (drops the entries that hold only it)\nlats call layers.unassign '{\"layer\":\"Coding\",\"wid\":9012}'\n\n# Tile a specific window\nlats call windows.place '{\"wid\":1234,\"placement\":\"left\"}'\n\n# Switch layer\nlats call layers.activate '{\"name\":\"Coding\",\"mode\":\"focus\"}'\n\n# List layers\nlats call layers.list\n\n# Rename / delete\nlats call layers.rename '{\"layer\":\"Coding\",\"name\":\"Deep work\"}'\nlats call layers.delete '{\"layer\":\"old-layer\"}'\n```\n\n## Composing Layers from Intent\n\nWhen a user says something high-level, here's how to think about it:\n\n### \"Make me a coding layer\"\n1. Find terminal windows (iTerm2, Terminal, Ghostty, kitty, Warp, etc.)\n2. Find browser windows with dev-related titles (GitHub, docs, localhost)\n3. Main editor/terminal on `left`, reference material on `right`\n\n### \"Set up a design layer\"\n1. Find design tools (Figma, Sketch, Adobe XD)\n2. Find browser windows with design references\n3. Design tool `left` (or `maximize`), references `right`\n\n### \"Create a writing layer\"\n1. Find text editors, notes apps (Notes, Obsidian, iA Writer, VS Code with .md)\n2. Find research/reference windows\n3. Writing app `left` or `center`, references `right`\n\n### \"Give me a communication layer\"\n1. Find messaging apps (Slack, Discord, Messages)\n2. Find email (Mail, Gmail in browser)\n3. Arrange by priority — primary tool `left`, secondary `right`\n\n### \"Split my work into layers by project\"\n1. Group windows by project (match on title keywords, session names, or app)\n2. Create one layer per project group\n3. Use the 3-window layout pattern: main `left`, support `top-right`, `bottom-right`\n\n## App Grouping Heuristics\n\nWhen deciding which windows go together:\n\n| Category | Common apps | Goes well with |\n|----------|-------------|----------------|\n| **Code** | iTerm2, Terminal, VS Code, Xcode | Chrome (docs/GitHub), Simulator |\n| **Design** | Figma, Sketch, Pixelmator | Chrome (design systems), Preview |\n| **Writing** | Notes, Obsidian, iA Writer | Chrome (research), Preview |\n| **Communication** | Slack, Discord, Messages, Mail | Calendar, Notes |\n| **Media** | Spotify, Music, Podcasts | (background, no tile needed) |\n| **Reference** | Chrome, Safari, Preview, Finder | (depends on content) |\n\nBrowser windows are chameleons — use `title` matching to assign them to the right layer based on their content.\n\n## Tips\n\n- Prefer `wid` when the windows are already open — it's unambiguous.\n- Don't put more than 4-5 windows in a single layer — it gets cramped.\n- Background apps (music, etc.) usually don't need to be in any layer.\n- The `snap` command is great for \"save what I have now\" scenarios.\n- Layers are saved as soon as they're made; `lats layer delete` removes one. The first save of each launch keeps the previous file as `workspace.json.bak`.\n- You can create multiple layers in sequence, then switch between them with `lats layer <name>` or ⌘⌥ + slot.\n",
      "content": "# Agent Guide: Generating Layers\n\nHow to create and manage Lattices workspace layers programmatically. This guide is for AI agents (Claude Code, etc.) that want to generate layers from high-level user descriptions.\n\n## Quick Reference\n\n```bash\n# See what's on screen\nlats windows --json\n\n# Create a layer with tiling\nlats layer create \"Design\" --json '[\n  {\"app\": \"Figma\", \"tile\": \"left\"},\n  {\"app\": \"Google Chrome\", \"title\": \"Tailwind\", \"tile\": \"right\"}\n]'\n\n# Snapshot current windows as a layer\nlats layer snap \"my-context\"\n\n# Add a window to the layer you're on, or to a named one\nlats layer add wid:1234\nlats layer add wid:1234 --to \"Design\"\nlats layer remove wid:1234 --from \"Design\"\n\n# List / switch / rename / delete\nlats layer\nlats layer \"Design\"\nlats layer rename \"Design\" \"Figma work\"\nlats layer delete \"Design\"\n```\n\n## How It Works\n\nLayers live in `~/.lattices/workspace.json`, and they're the ones on the ⌘⌥ pad: the first four layers are ⌘⌥1–4, the next four ⌘⌥6–9 (5 is the pad's centre), and any after that have no digit. The CLI, the daemon API, voice (\"save this layout as deploy\") and ⌘⌥T all write that file, and a change takes effect at once.\n\nEach window is saved as an entry of its app and its title at the time, which is how the layer finds it again after a restart. While the window stays open it's pinned in, so a title that changes later (a browser switching tabs) doesn't drop it. To make an entry broader, edit its `title` in `workspace.json` down to the part that stays put.\n\n## Step-by-Step: Generating a Layer\n\n### 1. Discover what's available\n\n```bash\nlats windows --json\n```\n\nReturns an array of window objects:\n```json\n[\n  {\n    \"wid\": 1234,\n    \"app\": \"iTerm2\",\n    \"title\": \"lattices — zsh\",\n    \"latticesSession\": \"lattices-abc123\",\n    \"frame\": { \"x\": 0, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"spaceIds\": [1]\n  },\n  {\n    \"wid\": 5678,\n    \"app\": \"Google Chrome\",\n    \"title\": \"GitHub - lattices-dev/lattices\",\n    \"frame\": { \"x\": 960, \"y\": 25, \"w\": 960, \"h\": 1050 },\n    \"spaceIds\": [1]\n  }\n]\n```\n\nKey fields for matching:\n- `wid` — unique window ID (most precise)\n- `app` — application name\n- `title` — window title (use for disambiguation when multiple windows of same app)\n- `latticesSession` — tmux session name (for terminal windows)\n\n### 2. Decide on a layout\n\nPick tile positions based on how many windows and what makes sense:\n\n| Windows | Good layout | Tile values |\n|---------|-------------|-------------|\n| 2 | Side by side | `left`, `right` |\n| 2 | Stacked | `top`, `bottom` |\n| 3 | Main + sidebar | `left` (60%), `top-right`, `bottom-right` |\n| 3 | Columns | `left-third`, `center-third`, `right-third` |\n| 4 | Quadrants | `top-left`, `top-right`, `bottom-left`, `bottom-right` |\n| 1 | Focused | `maximize` or `center` |\n\nFull position reference:\n- **Halves**: `left`, `right`, `top`, `bottom`\n- **Quarters**: `top-left`, `top-right`, `bottom-left`, `bottom-right`\n- **Thirds**: `left-third`, `center-third`, `right-third`\n- **Sixths**: `top-left-third`, `top-center-third`, `top-right-third`, `bottom-left-third`, `bottom-center-third`, `bottom-right-third`\n- **Fourths**: `first-fourth`, `second-fourth`, `third-fourth`, `last-fourth`\n- **Special**: `maximize`, `center`\n- **Custom grid**: `grid:CxR:C,R` (e.g. `grid:5x3:2,1`)\n\n### 3. Create the layer\n\n**Option A: By window ID (most reliable)**\n```bash\nlats layer create \"Coding\" --json '[\n  {\"wid\": 1234, \"tile\": \"left\"},\n  {\"wid\": 5678, \"tile\": \"right\"}\n]'\n```\n\n**Option B: By app name** (picks the first open window that matches)\n```bash\nlats layer create \"Research\" --json '[\n  {\"app\": \"Google Chrome\", \"title\": \"docs\", \"tile\": \"left\"},\n  {\"app\": \"Notes\", \"tile\": \"right\"}\n]'\n```\n\n**Option C: Simple wid list (no tiling)**\n```bash\nlats layer create \"Focus\" wid:1234 wid:5678\n```\n\n**Option D: Snapshot everything visible** (also what `create` does with no windows named)\n```bash\nlats layer snap \"Current Context\"\n```\n\n### 4. Switch between layers\n\n```bash\nlats layer           # list layers\nlats layer \"Coding\"  # switch to \"Coding\"\nlats layer 1         # switch to the layer on ⌘⌥1\n```\n\nOr press ⌘⌥ and the layer's slot number.\n\n## Daemon API (Advanced)\n\nFor finer control, use raw daemon calls:\n\n```bash\n# Create a layer from window IDs (omit windowIds to save what's on screen)\nlats call layers.create '{\"name\":\"Coding\",\"windowIds\":[1234,5678]}'\n\n# Add windows to a layer (default: the active one)\nlats call layers.assign '{\"layer\":\"Coding\",\"windowIds\":[9012]}'\n\n# Take a window out (drops the entries that hold only it)\nlats call layers.unassign '{\"layer\":\"Coding\",\"wid\":9012}'\n\n# Tile a specific window\nlats call windows.place '{\"wid\":1234,\"placement\":\"left\"}'\n\n# Switch layer\nlats call layers.activate '{\"name\":\"Coding\",\"mode\":\"focus\"}'\n\n# List layers\nlats call layers.list\n\n# Rename / delete\nlats call layers.rename '{\"layer\":\"Coding\",\"name\":\"Deep work\"}'\nlats call layers.delete '{\"layer\":\"old-layer\"}'\n```\n\n## Composing Layers from Intent\n\nWhen a user says something high-level, here's how to think about it:\n\n### \"Make me a coding layer\"\n1. Find terminal windows (iTerm2, Terminal, Ghostty, kitty, Warp, etc.)\n2. Find browser windows with dev-related titles (GitHub, docs, localhost)\n3. Main editor/terminal on `left`, reference material on `right`\n\n### \"Set up a design layer\"\n1. Find design tools (Figma, Sketch, Adobe XD)\n2. Find browser windows with design references\n3. Design tool `left` (or `maximize`), references `right`\n\n### \"Create a writing layer\"\n1. Find text editors, notes apps (Notes, Obsidian, iA Writer, VS Code with .md)\n2. Find research/reference windows\n3. Writing app `left` or `center`, references `right`\n\n### \"Give me a communication layer\"\n1. Find messaging apps (Slack, Discord, Messages)\n2. Find email (Mail, Gmail in browser)\n3. Arrange by priority — primary tool `left`, secondary `right`\n\n### \"Split my work into layers by project\"\n1. Group windows by project (match on title keywords, session names, or app)\n2. Create one layer per project group\n3. Use the 3-window layout pattern: main `left`, support `top-right`, `bottom-right`\n\n## App Grouping Heuristics\n\nWhen deciding which windows go together:\n\n| Category | Common apps | Goes well with |\n|----------|-------------|----------------|\n| **Code** | iTerm2, Terminal, VS Code, Xcode | Chrome (docs/GitHub), Simulator |\n| **Design** | Figma, Sketch, Pixelmator | Chrome (design systems), Preview |\n| **Writing** | Notes, Obsidian, iA Writer | Chrome (research), Preview |\n| **Communication** | Slack, Discord, Messages, Mail | Calendar, Notes |\n| **Media** | Spotify, Music, Podcasts | (background, no tile needed) |\n| **Reference** | Chrome, Safari, Preview, Finder | (depends on content) |\n\nBrowser windows are chameleons — use `title` matching to assign them to the right layer based on their content.\n\n## Tips\n\n- Prefer `wid` when the windows are already open — it's unambiguous.\n- Don't put more than 4-5 windows in a single layer — it gets cramped.\n- Background apps (music, etc.) usually don't need to be in any layer.\n- The `snap` command is great for \"save what I have now\" scenarios.\n- Layers are saved as soon as they're made; `lats layer delete` removes one. The first save of each launch keeps the previous file as `workspace.json.bak`.\n- You can create multiple layers in sequence, then switch between them with `lats layer <name>` or ⌘⌥ + slot."
    },
    {
      "id": "lattices:pi-lattices",
      "slug": "pi-lattices",
      "product": "lattices",
      "kind": "doc",
      "catalogKind": "guide",
      "current": true,
      "title": "Pi Lattices Extension",
      "description": "@arach/pi-lattices is a Pi package that turns the Lattices daemon into\nfirst-class Pi tools. It is implemented in packages/pi-lattices/ and registers\nstrictly-prefixed lattices tools on Pi session start.",
      "order": null,
      "sourcePath": "docs/pi-lattices.md",
      "url": "/lattices/docs/pi-lattices",
      "rawUrl": "/lattices/docs/markdown/pi-lattices.md",
      "markdownUrl": "/lattices/docs/pi-lattices.md",
      "headings": [
        {
          "depth": 1,
          "text": "Pi Lattices Extension",
          "anchor": "pi-lattices-extension"
        },
        {
          "depth": 2,
          "text": "Install",
          "anchor": "install"
        },
        {
          "depth": 2,
          "text": "Private connection",
          "anchor": "private-connection"
        },
        {
          "depth": 2,
          "text": "High-value tools",
          "anchor": "high-value-tools"
        },
        {
          "depth": 2,
          "text": "Smoke/manual test path",
          "anchor": "smoke-manual-test-path"
        }
      ],
      "tokensEstimate": 747,
      "frontmatter": {},
      "markdown": "# Pi Lattices Extension\n\n`@arach/pi-lattices` is a Pi package that turns the Lattices daemon into\nfirst-class Pi tools. It is implemented in `packages/pi-lattices/` and registers\nstrictly-prefixed `lattices_*` tools on Pi session start.\n\nThe extension preserves the Lattices runtime model:\n\n- It calls the existing macOS-native daemon. Lattices 0.15.1 uses\n  private IPC by default. Install the compatible extension from this checkout.\n- It does not bundle or route through `cua-driver`.\n- Run-backed computer-use tools keep Lattices `treatment` semantics.\n- Mutation wrappers prefer `treatment: \"stage\"` unless the caller explicitly\n  asks for `present` or `execute`.\n- Daemon-down failures are tool results with guidance, not registration-time\n  crashes.\n\n## Install\n\nFrom this repo during development:\n\n```bash\npi install ./packages/pi-lattices --local\n```\n\nPublished package form:\n\n```bash\npi install npm:@arach/pi-lattices\n```\n\nStart Lattices before using the tools:\n\n```bash\nlats app\n```\n\n## Private connection\n\nUse the extension and app from matching source. The extension finds\n`~/.lattices/run/lattices.sock` and its private token; no host or port setting\nis needed. Use the compatible extension from this checkout with Lattices 0.15.1.\n\nAn explicit `LATTICES_DAEMON_HOST` or `LATTICES_DAEMON_PORT` selects TCP. The\nextension sends no local token to that target unless\n`LATTICES_ALLOW_LOOPBACK_TCP=1` is also set and the target is loopback. Enable\nthat flag in the app too if you need its compatibility listener. An SSH tunnel\non `127.0.0.1` is still a network target, so do not enable compatibility for it.\n\nThe Pi extension does not sign remote gateway requests. A remote host/port\noverride alone cannot use a paired gateway; use the paired Lattices CLI or\nnative app for that path.\n\n## High-value tools\n\n| Pi tool | Lattices method |\n| --- | --- |\n| `lattices_status` | `daemon.status` |\n| `lattices_api_schema` | `api.schema` |\n| `lattices_windows_list` | `windows.list` |\n| `lattices_windows_search` | `windows.search` |\n| `lattices_window_get` | `windows.get` |\n| `lattices_runs_list` | `runs.list` |\n| `lattices_runs_get` | `runs.get` |\n| `lattices_ocr_snapshot` | `ocr.snapshot` |\n| `lattices_ocr_search` | `ocr.search` |\n| `lattices_computer_window_state` | `computer.windowState` |\n| `lattices_computer_element_action` | `computer.elementAction` |\n| `lattices_computer_type_element` | `computer.typeElement` |\n| `lattices_computer_set_value` | `computer.setValue` |\n| `lattices_computer_press_key` | `computer.pressKey` |\n| `lattices_computer_hotkey` | `computer.hotkey` |\n| `lattices_window_focus` | `computer.focusWindow` |\n| `lattices_window_place` | `windows.place` |\n| `lattices_capture_window` | `capture.screenshotWindow` |\n| `lattices_capture_region` | `capture.screenshotRegion` |\n| `lattices_capture_zoom_artifact` | `capture.zoomArtifact` |\n| `lattices_vision_analyze_window` | `vision.analyzeWindow` |\n| `lattices_vision_analyze_artifact` | `vision.analyzeArtifact` |\n| `lattices_computer_verify` | `computer.verify` |\n| `lattices_computer_prepare` | `computer.prepare` |\n| `lattices_computer_launch_app` | `computer.launchApp` |\n| `lattices_computer_click` | `computer.click` |\n| `lattices_computer_double_click` | `computer.doubleClick` |\n| `lattices_computer_right_click` | `computer.rightClick` |\n| `lattices_computer_scroll` | `computer.scroll` |\n| `lattices_computer_drag` | `computer.drag` |\n| `lattices_computer_type_window_text` | `computer.typeWindowText` |\n| `lattices_computer_type_text` | `computer.typeText` |\n| `lattices_browser_get_text` | `browser.getText` |\n| `lattices_browser_query_dom` | `browser.queryDom` |\n| `lattices_browser_execute_javascript` | `browser.executeJavascript` |\n| `lattices_call` | caller-selected escape hatch |\n\nUse `lattices_call` only when a method is not typed yet. It passes through the\nrequested daemon method and does not add an additional confirmation layer.\nBrowser DOM/JavaScript tools are intentionally explicit: DOM queries require\n`allowAutomation: true`, and JavaScript execution also requires\n`treatment: \"execute\"`.\n\n## Smoke/manual test path\n\nAutomated registration smoke:\n\n```bash\nnode packages/pi-lattices/test/smoke.mjs\n```\n\nDaemon-down behavior:\n\n```bash\nbun run --cwd packages/pi-lattices smoke:no-daemon\n```\n\nLive status + safe staged call:\n\n```bash\nlats app\nbun run --cwd packages/pi-lattices smoke:live\n```\n\nThe live smoke checks `daemon.status`, then stages a `computer.launchApp` run for\nFinder with `capture: false`. Because the call uses `treatment: \"stage\"`, it\nrecords intent without launching or focusing the app.\n",
      "content": "# Pi Lattices Extension\n\n`@arach/pi-lattices` is a Pi package that turns the Lattices daemon into\nfirst-class Pi tools. It is implemented in `packages/pi-lattices/` and registers\nstrictly-prefixed `lattices_*` tools on Pi session start.\n\nThe extension preserves the Lattices runtime model:\n\n- It calls the existing macOS-native daemon. Lattices 0.15.1 uses\n  private IPC by default. Install the compatible extension from this checkout.\n- It does not bundle or route through `cua-driver`.\n- Run-backed computer-use tools keep Lattices `treatment` semantics.\n- Mutation wrappers prefer `treatment: \"stage\"` unless the caller explicitly\n  asks for `present` or `execute`.\n- Daemon-down failures are tool results with guidance, not registration-time\n  crashes.\n\n## Install\n\nFrom this repo during development:\n\n```bash\npi install ./packages/pi-lattices --local\n```\n\nPublished package form:\n\n```bash\npi install npm:@arach/pi-lattices\n```\n\nStart Lattices before using the tools:\n\n```bash\nlats app\n```\n\n## Private connection\n\nUse the extension and app from matching source. The extension finds\n`~/.lattices/run/lattices.sock` and its private token; no host or port setting\nis needed. Use the compatible extension from this checkout with Lattices 0.15.1.\n\nAn explicit `LATTICES_DAEMON_HOST` or `LATTICES_DAEMON_PORT` selects TCP. The\nextension sends no local token to that target unless\n`LATTICES_ALLOW_LOOPBACK_TCP=1` is also set and the target is loopback. Enable\nthat flag in the app too if you need its compatibility listener. An SSH tunnel\non `127.0.0.1` is still a network target, so do not enable compatibility for it.\n\nThe Pi extension does not sign remote gateway requests. A remote host/port\noverride alone cannot use a paired gateway; use the paired Lattices CLI or\nnative app for that path.\n\n## High-value tools\n\n| Pi tool | Lattices method |\n| --- | --- |\n| `lattices_status` | `daemon.status` |\n| `lattices_api_schema` | `api.schema` |\n| `lattices_windows_list` | `windows.list` |\n| `lattices_windows_search` | `windows.search` |\n| `lattices_window_get` | `windows.get` |\n| `lattices_runs_list` | `runs.list` |\n| `lattices_runs_get` | `runs.get` |\n| `lattices_ocr_snapshot` | `ocr.snapshot` |\n| `lattices_ocr_search` | `ocr.search` |\n| `lattices_computer_window_state` | `computer.windowState` |\n| `lattices_computer_element_action` | `computer.elementAction` |\n| `lattices_computer_type_element` | `computer.typeElement` |\n| `lattices_computer_set_value` | `computer.setValue` |\n| `lattices_computer_press_key` | `computer.pressKey` |\n| `lattices_computer_hotkey` | `computer.hotkey` |\n| `lattices_window_focus` | `computer.focusWindow` |\n| `lattices_window_place` | `windows.place` |\n| `lattices_capture_window` | `capture.screenshotWindow` |\n| `lattices_capture_region` | `capture.screenshotRegion` |\n| `lattices_capture_zoom_artifact` | `capture.zoomArtifact` |\n| `lattices_vision_analyze_window` | `vision.analyzeWindow` |\n| `lattices_vision_analyze_artifact` | `vision.analyzeArtifact` |\n| `lattices_computer_verify` | `computer.verify` |\n| `lattices_computer_prepare` | `computer.prepare` |\n| `lattices_computer_launch_app` | `computer.launchApp` |\n| `lattices_computer_click` | `computer.click` |\n| `lattices_computer_double_click` | `computer.doubleClick` |\n| `lattices_computer_right_click` | `computer.rightClick` |\n| `lattices_computer_scroll` | `computer.scroll` |\n| `lattices_computer_drag` | `computer.drag` |\n| `lattices_computer_type_window_text` | `computer.typeWindowText` |\n| `lattices_computer_type_text` | `computer.typeText` |\n| `lattices_browser_get_text` | `browser.getText` |\n| `lattices_browser_query_dom` | `browser.queryDom` |\n| `lattices_browser_execute_javascript` | `browser.executeJavascript` |\n| `lattices_call` | caller-selected escape hatch |\n\nUse `lattices_call` only when a method is not typed yet. It passes through the\nrequested daemon method and does not add an additional confirmation layer.\nBrowser DOM/JavaScript tools are intentionally explicit: DOM queries require\n`allowAutomation: true`, and JavaScript execution also requires\n`treatment: \"execute\"`.\n\n## Smoke/manual test path\n\nAutomated registration smoke:\n\n```bash\nnode packages/pi-lattices/test/smoke.mjs\n```\n\nDaemon-down behavior:\n\n```bash\nbun run --cwd packages/pi-lattices smoke:no-daemon\n```\n\nLive status + safe staged call:\n\n```bash\nlats app\nbun run --cwd packages/pi-lattices smoke:live\n```\n\nThe live smoke checks `daemon.status`, then stages a `computer.launchApp` run for\nFinder with `capture: false`. Because the call uses `treatment: \"stage\"`, it\nrecords intent without launching or focusing the app."
    },
    {
      "id": "lattices:embedded-sdk",
      "slug": "embedded-sdk",
      "product": "lattices",
      "kind": "reference",
      "catalogKind": "reference",
      "current": true,
      "title": "Embedded SDK",
      "description": "Embedding LatticesKit into host apps and Scout-owned helpers",
      "order": 30,
      "sourcePath": "docs/embedded-sdk.md",
      "url": "/lattices/docs/embedded-sdk",
      "rawUrl": "/lattices/docs/markdown/embedded-sdk.md",
      "markdownUrl": "/lattices/docs/embedded-sdk.md",
      "headings": [
        {
          "depth": 2,
          "text": "Core principle",
          "anchor": "core-principle"
        },
        {
          "depth": 2,
          "text": "Ownership models",
          "anchor": "ownership-models"
        },
        {
          "depth": 3,
          "text": "Main app owned",
          "anchor": "main-app-owned"
        },
        {
          "depth": 3,
          "text": "Helper owned",
          "anchor": "helper-owned"
        },
        {
          "depth": 2,
          "text": "Naming convention",
          "anchor": "naming-convention"
        },
        {
          "depth": 2,
          "text": "Permission ownership",
          "anchor": "permission-ownership"
        },
        {
          "depth": 2,
          "text": "Suggested Scout shape",
          "anchor": "suggested-scout-shape"
        },
        {
          "depth": 2,
          "text": "Relationship to the daemon",
          "anchor": "relationship-to-the-daemon"
        },
        {
          "depth": 2,
          "text": "Relationship to HudsonKit",
          "anchor": "relationship-to-hudsonkit"
        }
      ],
      "tokensEstimate": 1092,
      "frontmatter": {
        "title": "Embedded SDK",
        "description": "Embedding LatticesKit into host apps and Scout-owned helpers",
        "order": 30
      },
      "markdown": "---\ntitle: Embedded SDK\ndescription: Embedding LatticesKit into host apps and Scout-owned helpers\norder: 30\n---\n\nLatticesKit is the embeddable form of lattices. It is a Swift package that\nhost apps compile into their own process or into a helper app they own. The\nimportant boundary is product ownership: users should grant permissions to the\nhost product, not to a separate Lattices runtime.\n\n## Core principle\n\nLattices can provide the implementation for workspace capabilities without\nowning the macOS trust boundary.\n\nFor Scout, that means LatticesKit can power session navigation, tmux control,\nwindow management, Spaces switching, Accessibility inspection, screen capture,\nand input helpers while macOS shows a Scout-owned app in Privacy & Security.\n\nLatticesKit should not require users to install or trust `Lattices.app` just\nbecause Scout wants to embed lattices capabilities.\n\n## Ownership models\n\nThere are two supported embedding shapes.\n\n### Main app owned\n\nThe host app links LatticesKit directly:\n\n```text\nScout.app\n└── links LatticesKit\n```\n\nmacOS permissions belong to `Scout.app`. This is the simplest model when the\nmain app has the right lifecycle for global shortcuts, window observation, and\ndesktop control.\n\n### Helper owned\n\nThe host ships a product-owned helper that links LatticesKit:\n\n```text\nScout.app\n└── Contents/Resources/Scout Workspace Helper.app\n    └── links LatticesKit\n```\n\nmacOS permissions belong to the helper. The main app talks to it through an\ninternal boundary such as XPC or an authenticated local channel. When the host\nproduct supports remote control, the same capability owner can also be exposed\nover a paired LAN, mesh, or Tailscale-style channel.\n\nThis follows the useful part of Orca's `Orca Computer Use.app` pattern: a\nbundled helper owns sensitive desktop permissions. The difference is that the\nhelper is Scout-owned, Scout-branded, and installed with Scout. Lattices remains\nan implementation library inside that helper.\n\n## Naming convention\n\nUse helper names to explain the product trust boundary.\n\n| Name | Meaning |\n|------|---------|\n| `Scout Helper` | General Scout-owned helper for product logic, menu bar lifecycle, or broad coordination |\n| `Scout <domain> Helper` | Scout-owned helper for a specific embedded capability domain |\n| `Lattices Helper` | Avoid. This makes Lattices look like the permission owner |\n\nFor a LatticesKit integration, good names are:\n\n- `Scout Helper` when one broad helper owns all Scout desktop capabilities\n- `Scout Workspace Helper` when the helper specifically owns workspace,\n  session, tmux, window, and Spaces capabilities\n- `Scout Computer Use Helper` only when the capability is intentionally framed\n  as general app control rather than workspace management\n\nThe rule is: split helpers by trust boundary and lifecycle, not by library\nboundary. Do not create one helper per embedded package unless the user-facing\npermission story or blast radius truly improves.\n\n## Permission ownership\n\nThe owner bundle must carry the macOS usage descriptions for the capabilities it\nperforms:\n\n- `NSAccessibilityUsageDescription`\n- `NSScreenCaptureUsageDescription`\n\nInput Monitoring does not use the same Info.plist prompt shape, but the owner\nbundle is still what macOS lists in Privacy & Security when the helper requests\nor checks listen-event access.\n\nLatticesKit provides APIs for status, readiness, settings links, and requests.\nThe host or helper provides the user experience around those APIs.\n\nExample:\n\n```swift\nimport LatticesKit\n\nlet lattices = Lattices()\nlet readiness = lattices.readiness(for: [.sessionNavigation, .windowManagement])\n\nif let firstMissing = readiness.missing.first {\n    lattices.permissions.request(firstMissing)\n}\n```\n\nIf this code runs in `Scout Workspace Helper.app`, TCC belongs to Scout\nWorkspace Helper. If it runs in `Scout.app`, TCC belongs to Scout.\n\n## Suggested Scout shape\n\nFor Scout, the preferred default is a Scout-owned helper that embeds\nLatticesKit:\n\n```text\nScout.app\n├── product UI and orchestration\n└── Scout Workspace Helper.app\n    ├── LatticesKit\n    ├── Accessibility and Screen Recording usage strings\n    ├── global hotkeys / Input Monitoring if needed\n    ├── window discovery, focus, tiling, and Spaces switching\n    ├── tmux session launch, restart, detach, and navigation\n    └── AX snapshots and controlled input\n```\n\nThe helper should expose a narrow command surface to Scout and to any paired\nmesh clients:\n\n- capability and permission readiness\n- session name, launch, sync, restart, detach, kill\n- window list, resolve, focus, tile, place\n- Accessibility snapshot\n- input actions that Scout explicitly requests\n\nLAN, mesh, and Tailscale-style reachability must be explicit product surfaces,\nnot accidental daemon exposure. They should require pairing, authenticated\nrequests, capability scoping, visible connection state, and revocation.\n\nThe helper should not expose Lattices as a separate product identity. From the\nuser's perspective this is Scout doing workspace control.\n\n## Relationship to the daemon\n\nThe existing Lattices daemon remains useful for the standalone app, CLI,\nscripts, diagnostics, and external automation.\n\nIt is not the embedded SDK boundary.\n\nEmbedded hosts should prefer LatticesKit in-process or in a product-owned\nhelper. A daemon-backed client can still exist as an adapter for tools that\nexplicitly want to talk to a running Lattices app.\n\n## Relationship to HudsonKit\n\nHudsonKit hosts can make the same choice:\n\n- link LatticesKit directly when the host app should own permissions\n- ship a `Hudson Helper` or `Hudson Workspace Helper` when the capability needs\n  a long-lived helper lifecycle\n\nThe reusable artifact is still LatticesKit. The permission owner should always\nbe the embedding product or its branded helper.\n",
      "content": "LatticesKit is the embeddable form of lattices. It is a Swift package that\nhost apps compile into their own process or into a helper app they own. The\nimportant boundary is product ownership: users should grant permissions to the\nhost product, not to a separate Lattices runtime.\n\n## Core principle\n\nLattices can provide the implementation for workspace capabilities without\nowning the macOS trust boundary.\n\nFor Scout, that means LatticesKit can power session navigation, tmux control,\nwindow management, Spaces switching, Accessibility inspection, screen capture,\nand input helpers while macOS shows a Scout-owned app in Privacy & Security.\n\nLatticesKit should not require users to install or trust `Lattices.app` just\nbecause Scout wants to embed lattices capabilities.\n\n## Ownership models\n\nThere are two supported embedding shapes.\n\n### Main app owned\n\nThe host app links LatticesKit directly:\n\n```text\nScout.app\n└── links LatticesKit\n```\n\nmacOS permissions belong to `Scout.app`. This is the simplest model when the\nmain app has the right lifecycle for global shortcuts, window observation, and\ndesktop control.\n\n### Helper owned\n\nThe host ships a product-owned helper that links LatticesKit:\n\n```text\nScout.app\n└── Contents/Resources/Scout Workspace Helper.app\n    └── links LatticesKit\n```\n\nmacOS permissions belong to the helper. The main app talks to it through an\ninternal boundary such as XPC or an authenticated local channel. When the host\nproduct supports remote control, the same capability owner can also be exposed\nover a paired LAN, mesh, or Tailscale-style channel.\n\nThis follows the useful part of Orca's `Orca Computer Use.app` pattern: a\nbundled helper owns sensitive desktop permissions. The difference is that the\nhelper is Scout-owned, Scout-branded, and installed with Scout. Lattices remains\nan implementation library inside that helper.\n\n## Naming convention\n\nUse helper names to explain the product trust boundary.\n\n| Name | Meaning |\n|------|---------|\n| `Scout Helper` | General Scout-owned helper for product logic, menu bar lifecycle, or broad coordination |\n| `Scout <domain> Helper` | Scout-owned helper for a specific embedded capability domain |\n| `Lattices Helper` | Avoid. This makes Lattices look like the permission owner |\n\nFor a LatticesKit integration, good names are:\n\n- `Scout Helper` when one broad helper owns all Scout desktop capabilities\n- `Scout Workspace Helper` when the helper specifically owns workspace,\n  session, tmux, window, and Spaces capabilities\n- `Scout Computer Use Helper` only when the capability is intentionally framed\n  as general app control rather than workspace management\n\nThe rule is: split helpers by trust boundary and lifecycle, not by library\nboundary. Do not create one helper per embedded package unless the user-facing\npermission story or blast radius truly improves.\n\n## Permission ownership\n\nThe owner bundle must carry the macOS usage descriptions for the capabilities it\nperforms:\n\n- `NSAccessibilityUsageDescription`\n- `NSScreenCaptureUsageDescription`\n\nInput Monitoring does not use the same Info.plist prompt shape, but the owner\nbundle is still what macOS lists in Privacy & Security when the helper requests\nor checks listen-event access.\n\nLatticesKit provides APIs for status, readiness, settings links, and requests.\nThe host or helper provides the user experience around those APIs.\n\nExample:\n\n```swift\nimport LatticesKit\n\nlet lattices = Lattices()\nlet readiness = lattices.readiness(for: [.sessionNavigation, .windowManagement])\n\nif let firstMissing = readiness.missing.first {\n    lattices.permissions.request(firstMissing)\n}\n```\n\nIf this code runs in `Scout Workspace Helper.app`, TCC belongs to Scout\nWorkspace Helper. If it runs in `Scout.app`, TCC belongs to Scout.\n\n## Suggested Scout shape\n\nFor Scout, the preferred default is a Scout-owned helper that embeds\nLatticesKit:\n\n```text\nScout.app\n├── product UI and orchestration\n└── Scout Workspace Helper.app\n    ├── LatticesKit\n    ├── Accessibility and Screen Recording usage strings\n    ├── global hotkeys / Input Monitoring if needed\n    ├── window discovery, focus, tiling, and Spaces switching\n    ├── tmux session launch, restart, detach, and navigation\n    └── AX snapshots and controlled input\n```\n\nThe helper should expose a narrow command surface to Scout and to any paired\nmesh clients:\n\n- capability and permission readiness\n- session name, launch, sync, restart, detach, kill\n- window list, resolve, focus, tile, place\n- Accessibility snapshot\n- input actions that Scout explicitly requests\n\nLAN, mesh, and Tailscale-style reachability must be explicit product surfaces,\nnot accidental daemon exposure. They should require pairing, authenticated\nrequests, capability scoping, visible connection state, and revocation.\n\nThe helper should not expose Lattices as a separate product identity. From the\nuser's perspective this is Scout doing workspace control.\n\n## Relationship to the daemon\n\nThe existing Lattices daemon remains useful for the standalone app, CLI,\nscripts, diagnostics, and external automation.\n\nIt is not the embedded SDK boundary.\n\nEmbedded hosts should prefer LatticesKit in-process or in a product-owned\nhelper. A daemon-backed client can still exist as an adapter for tools that\nexplicitly want to talk to a running Lattices app.\n\n## Relationship to HudsonKit\n\nHudsonKit hosts can make the same choice:\n\n- link LatticesKit directly when the host app should own permissions\n- ship a `Hudson Helper` or `Hudson Workspace Helper` when the capability needs\n  a long-lived helper lifecycle\n\nThe reusable artifact is still LatticesKit. The permission owner should always\nbe the embedding product or its branded helper."
    },
    {
      "id": "lattices:terminal-kit",
      "slug": "terminal-kit",
      "product": "lattices",
      "kind": "reference",
      "catalogKind": "reference",
      "current": true,
      "title": "LatticesTerminalKit",
      "description": "LatticesTerminalKit is the reusable Swift terminal-inventory slice of\nlattices. It is designed for native hosts such as Scout that should embed the\nsame macOS probes directly instead of talking to the lattices daemon or ",
      "order": null,
      "sourcePath": "docs/terminal-kit.md",
      "url": "/lattices/docs/terminal-kit",
      "rawUrl": "/lattices/docs/markdown/terminal-kit.md",
      "markdownUrl": "/lattices/docs/terminal-kit.md",
      "headings": [
        {
          "depth": 1,
          "text": "LatticesTerminalKit",
          "anchor": "latticesterminalkit"
        },
        {
          "depth": 2,
          "text": "V1 Scope",
          "anchor": "v1-scope"
        },
        {
          "depth": 2,
          "text": "Import",
          "anchor": "import"
        },
        {
          "depth": 2,
          "text": "Snapshot Shape",
          "anchor": "snapshot-shape"
        },
        {
          "depth": 2,
          "text": "Join Strategy",
          "anchor": "join-strategy"
        },
        {
          "depth": 2,
          "text": "Notes For Scout",
          "anchor": "notes-for-scout"
        }
      ],
      "tokensEstimate": 454,
      "frontmatter": {},
      "markdown": "# LatticesTerminalKit\n\n`LatticesTerminalKit` is the reusable Swift terminal-inventory slice of\nlattices. It is designed for native hosts such as Scout that should embed the\nsame macOS probes directly instead of talking to the lattices daemon or CLI.\n\n## V1 Scope\n\nThe first slice inventories:\n\n- Apple Terminal\n- iTerm2\n- Ghostty\n\nIt intentionally stays read-oriented. Focus and placement actions can build on\nthe returned handles later, but the v1 contract is an observed terminal\nprojection, not a canonical session or message store.\n\n## Import\n\nThe product lives in the repo's reusable Swift package:\n\n```swift\nimport LatticesTerminalKit\n\nlet snapshot = TerminalInventory.snapshot()\n```\n\nFor bounded tmux pane context:\n\n```swift\nlet snapshot = TerminalInventory.snapshot(options: .init(\n    includePaneContent: true,\n    paneContentLineLimit: 120\n))\n```\n\n## Snapshot Shape\n\n`TerminalInventorySnapshot` contains:\n\n- `snapshotId`\n- `observedAt`\n- `terminals`\n- `tmuxSessions`\n- `terminalTabs`\n- `terminalWindows`\n\nEach `TerminalInstance` includes:\n\n- identity: `stableKey`, `tty`, app name, bundle id, app pid\n- terminal UI: window id/title, tab index/title, terminal session id\n- process context: interesting process rows, shell pid, cwd, cwd source\n- tmux context: session and pane id\n- harness detection: `detectedHarnesses` for Claude and Codex signals\n- action preparation: `focusHandle`, `placementHandle`, `capabilities`\n- trust metadata: `provenance` with confidence for tty/cwd/window/tmux/harness\n- optional inspection: bounded `paneCapture`\n\n## Join Strategy\n\nThe kit joins by TTY first.\n\nInputs:\n\n- process table from `ps`\n- cwd lookup from batched `lsof`\n- tmux sessions and panes\n- AppleScript tab probes for Terminal and iTerm2\n- CG window inventory for Terminal, iTerm2, and Ghostty\n\nWindow resolution order:\n\n1. lattices title tag: `[lattices:<session>]`\n2. app window index from Terminal/iTerm2 tab probes\n3. CG owner process tree TTY, used especially for Ghostty\n\nGhostty is expected to be window-level in v1. The kit does not synthesize stable\ntab ids from titles or z-order.\n\n## Notes For Scout\n\nCG window IDs are useful but volatile. Consumers should pair them with\n`stableKey`, app pid, bundle id, tty, and tmux pane identity where available.\n\nPane capture is opt-in and ephemeral. It should be treated as drill-down context,\nnot as durable chat or terminal history.\n",
      "content": "# LatticesTerminalKit\n\n`LatticesTerminalKit` is the reusable Swift terminal-inventory slice of\nlattices. It is designed for native hosts such as Scout that should embed the\nsame macOS probes directly instead of talking to the lattices daemon or CLI.\n\n## V1 Scope\n\nThe first slice inventories:\n\n- Apple Terminal\n- iTerm2\n- Ghostty\n\nIt intentionally stays read-oriented. Focus and placement actions can build on\nthe returned handles later, but the v1 contract is an observed terminal\nprojection, not a canonical session or message store.\n\n## Import\n\nThe product lives in the repo's reusable Swift package:\n\n```swift\nimport LatticesTerminalKit\n\nlet snapshot = TerminalInventory.snapshot()\n```\n\nFor bounded tmux pane context:\n\n```swift\nlet snapshot = TerminalInventory.snapshot(options: .init(\n    includePaneContent: true,\n    paneContentLineLimit: 120\n))\n```\n\n## Snapshot Shape\n\n`TerminalInventorySnapshot` contains:\n\n- `snapshotId`\n- `observedAt`\n- `terminals`\n- `tmuxSessions`\n- `terminalTabs`\n- `terminalWindows`\n\nEach `TerminalInstance` includes:\n\n- identity: `stableKey`, `tty`, app name, bundle id, app pid\n- terminal UI: window id/title, tab index/title, terminal session id\n- process context: interesting process rows, shell pid, cwd, cwd source\n- tmux context: session and pane id\n- harness detection: `detectedHarnesses` for Claude and Codex signals\n- action preparation: `focusHandle`, `placementHandle`, `capabilities`\n- trust metadata: `provenance` with confidence for tty/cwd/window/tmux/harness\n- optional inspection: bounded `paneCapture`\n\n## Join Strategy\n\nThe kit joins by TTY first.\n\nInputs:\n\n- process table from `ps`\n- cwd lookup from batched `lsof`\n- tmux sessions and panes\n- AppleScript tab probes for Terminal and iTerm2\n- CG window inventory for Terminal, iTerm2, and Ghostty\n\nWindow resolution order:\n\n1. lattices title tag: `[lattices:<session>]`\n2. app window index from Terminal/iTerm2 tab probes\n3. CG owner process tree TTY, used especially for Ghostty\n\nGhostty is expected to be window-level in v1. The kit does not synthesize stable\ntab ids from titles or z-order.\n\n## Notes For Scout\n\nCG window IDs are useful but volatile. Consumers should pair them with\n`stableKey`, app pid, bundle id, tty, and tmux pane identity where available.\n\nPane capture is opt-in and ephemeral. It should be treated as drill-down context,\nnot as durable chat or terminal history."
    },
    {
      "id": "lattices:security-identity",
      "slug": "security-identity",
      "product": "lattices",
      "kind": "reference",
      "catalogKind": "reference",
      "current": true,
      "title": "Device identity",
      "description": "Each machine has one device identity: one X25519 key and one id. Lattices uses\nthat key for every role that needs a key. Before this change, each role kept\nits own key file.",
      "order": null,
      "sourcePath": "docs/security-identity.md",
      "url": "/lattices/docs/security-identity",
      "rawUrl": "/lattices/docs/markdown/security-identity.md",
      "markdownUrl": "/lattices/docs/security-identity.md",
      "headings": [
        {
          "depth": 1,
          "text": "Device identity",
          "anchor": "device-identity"
        },
        {
          "depth": 2,
          "text": "The file",
          "anchor": "the-file"
        },
        {
          "depth": 2,
          "text": "Key map",
          "anchor": "key-map"
        },
        {
          "depth": 2,
          "text": "Why one key is safe",
          "anchor": "why-one-key-is-safe"
        },
        {
          "depth": 2,
          "text": "Migration",
          "anchor": "migration"
        },
        {
          "depth": 3,
          "text": "Effect on peers",
          "anchor": "effect-on-peers"
        },
        {
          "depth": 3,
          "text": "Mixed versions",
          "anchor": "mixed-versions"
        },
        {
          "depth": 2,
          "text": "Related",
          "anchor": "related"
        }
      ],
      "tokensEstimate": 1338,
      "frontmatter": {},
      "markdown": "# Device identity\n\nEach machine has one device identity: one X25519 key and one id. Lattices uses\nthat key for every role that needs a key. Before this change, each role kept\nits own key file.\n\n## The file\n\n`~/.lattices/identity/device.json`, mode 0600, in a 0700 directory:\n\n```json\n{\"version\":1,\"id\":\"<uuid>\",\"name\":\"<host>\",\"privateKey\":{\"kty\":\"OKP\",\"crv\":\"X25519\",\"x\":\"…\",\"d\":\"…\"}}\n```\n\nTwo modules read and write this file. Their behavior must stay the same:\n\n- `packages/desktop-client/device-identity.ts` (CLI, gateway, Linux host)\n- `apps/mac/Sources/Core/System/DeviceIdentity.swift` (Mac app)\n\nA reader accepts the file only if all of these are true:\n\n- it is a regular file with one link, opened without following a symlink;\n- the current user owns it;\n- its mode gives no access to group or other;\n- it is 64 KB or smaller;\n- `x` is the public key of `d`.\n\nIf the file is unsafe or malformed, the reader stops with an error. It must not\nreplace the file. The Mac app then uses a key for the current session only and\nwrites a warning to the diagnostic log. Peers must re-pair with that session.\n\nA writer creates the file once. It writes a private temporary file and\nhard-links it into place. If the link fails because the file exists, another\nprocess created the identity first, and the writer reads that file. The CLI\nand the app can start at the same time and still agree on one key.\n\n## Key map\n\n| Role | Before | Now | Who pins the public key |\n| --- | --- | --- | --- |\n| Gateway server | `gateway/gateway-key.json` (JWK) | device key | gateway clients, in their `paired-hosts.json` |\n| Gateway client | `client.json` (id, name, JWK) | device key and id | remote gateways, in `gateway/gateway-clients.json` |\n| Mac companion bridge | `companion/bridge.key` (raw 32 bytes); older builds used a Keychain item | device key | the iPad, and Macs that visit this Mac |\n| Linux companion bridge | `host/bridge-key.json` (JWK) | device key | the iPad, and Macs that visit this host |\n| Visit client | `visit/device-key` (raw 32 bytes) | device key | the bridges this Mac visits |\n\nThese stay separate:\n\n| Item | Reason |\n| --- | --- |\n| The iPad key (`DeckBridgeSecurityStore`) | The iPad is a separate device with its own identity. |\n| The Talkie local client key (P-256 signing, Keychain) | The Talkie protocol requires P-256 ECDSA. An X25519 key cannot sign. |\n| The `visit-test` key (`--identity`) | The test tool simulates a second device. |\n| Trust records: `gateway/gateway-clients.json`, `host/bridge-devices.json`, the companion trusted-device list, `visit/hosts.json`, `paired-hosts.json` | They hold peers' keys, not this machine's key. |\n\n## Why one key is safe\n\nAll roles use the same primitives: X25519 key agreement, HKDF-SHA256 with salt\n`lattices-bridge-v1`, an HMAC-SHA256 request signature, and ChaChaPoly for\nencrypted bodies. Reuse of one key across these roles does not let a peer\nreplay a message into another role:\n\n- Each signature covers the method, path, device id, timestamp, nonce and body\n  hash.\n- Each encrypted body uses a distinct AAD for its direction (`request`,\n  `response`, `visit`).\n- Each trust store is keyed by the peer's id and grants only its own scopes.\n  A gateway pairing does not grant companion access, and a companion pairing\n  does not grant gateway access.\n\nRole ids must stay distinct. The gateway client id comes from the identity.\nThe visit device id stays in `visit/hosts.json`. Do not merge these ids.\n\nThe fingerprint is the same in every view: the first 12 hex digits of SHA-256\nover the base64 public key, as `XXXX-XXXX-XXXX`. The Mac bridge fingerprint in\nCompanion settings and the visit code in Machines now show the same value.\n\n## Migration\n\nThe first process that needs the identity creates it from the old files. It\nselects the first key that it can read, in this order:\n\n1. `gateway/gateway-key.json`, only if `gateway/gateway-clients.json` lists at\n   least one client;\n2. `companion/bridge.key`;\n3. `host/bridge-key.json`;\n4. `gateway/gateway-key.json`;\n5. the key in `client.json`;\n6. `visit/device-key`.\n\nIf no file has a usable key, the process generates a new key. The id and name\ncome from `client.json` if it is valid. If not, the id is a new UUID and the\nname is the host name.\n\nThe order keeps the key that the most peers pin. A gateway key that no client\npinned has no value, so the companion key comes before it. The iPad cannot\nre-pair without a person at the device, so the companion key comes before the\nclient and visit keys.\n\nAfter the link succeeds, the process deletes the five old key files. It keeps\n`gateway/gateway-clients.json`. The Keychain item from old Mac builds is not\nread and not deleted.\n\n### Effect on peers\n\nPeers that pinned the adopted key continue to work. Peers that pinned another\nold key must re-pair once.\n\n| Machine | Kept | Re-pair |\n| --- | --- | --- |\n| Mac with no gateway clients | companion key: the iPad and visiting Macs continue | this Mac's own gateway pairings (`lats hosts pair`) and visit pairings |\n| Mac with gateway clients | gateway key: its gateway clients continue | the iPad, visiting Macs, and this Mac's own outgoing pairings |\n| Linux host (archie) with gateway clients | gateway key: its gateway clients continue | the iPad, Macs that visit the bridge, and its own outgoing pairings |\n| Linux host with no gateway clients | bridge key: the iPad and visiting Macs continue | its own outgoing pairings |\n\n### Mixed versions\n\nAn old build does not read `device.json`. If an old build runs after the\nmigration, it creates new per-role key files with new keys. Peers that pair\nwith that build pin keys that the new build deletes or ignores. Update the CLI,\nthe gateway and the app on a machine together, and restart all three.\n\n## Related\n\nLAT-014 (paired Macs) proposes a P-256 TLS identity in the Keychain. That\nproposal is not built. If it is built, it should explain why it cannot use the\ndevice identity.\n",
      "content": "# Device identity\n\nEach machine has one device identity: one X25519 key and one id. Lattices uses\nthat key for every role that needs a key. Before this change, each role kept\nits own key file.\n\n## The file\n\n`~/.lattices/identity/device.json`, mode 0600, in a 0700 directory:\n\n```json\n{\"version\":1,\"id\":\"<uuid>\",\"name\":\"<host>\",\"privateKey\":{\"kty\":\"OKP\",\"crv\":\"X25519\",\"x\":\"…\",\"d\":\"…\"}}\n```\n\nTwo modules read and write this file. Their behavior must stay the same:\n\n- `packages/desktop-client/device-identity.ts` (CLI, gateway, Linux host)\n- `apps/mac/Sources/Core/System/DeviceIdentity.swift` (Mac app)\n\nA reader accepts the file only if all of these are true:\n\n- it is a regular file with one link, opened without following a symlink;\n- the current user owns it;\n- its mode gives no access to group or other;\n- it is 64 KB or smaller;\n- `x` is the public key of `d`.\n\nIf the file is unsafe or malformed, the reader stops with an error. It must not\nreplace the file. The Mac app then uses a key for the current session only and\nwrites a warning to the diagnostic log. Peers must re-pair with that session.\n\nA writer creates the file once. It writes a private temporary file and\nhard-links it into place. If the link fails because the file exists, another\nprocess created the identity first, and the writer reads that file. The CLI\nand the app can start at the same time and still agree on one key.\n\n## Key map\n\n| Role | Before | Now | Who pins the public key |\n| --- | --- | --- | --- |\n| Gateway server | `gateway/gateway-key.json` (JWK) | device key | gateway clients, in their `paired-hosts.json` |\n| Gateway client | `client.json` (id, name, JWK) | device key and id | remote gateways, in `gateway/gateway-clients.json` |\n| Mac companion bridge | `companion/bridge.key` (raw 32 bytes); older builds used a Keychain item | device key | the iPad, and Macs that visit this Mac |\n| Linux companion bridge | `host/bridge-key.json` (JWK) | device key | the iPad, and Macs that visit this host |\n| Visit client | `visit/device-key` (raw 32 bytes) | device key | the bridges this Mac visits |\n\nThese stay separate:\n\n| Item | Reason |\n| --- | --- |\n| The iPad key (`DeckBridgeSecurityStore`) | The iPad is a separate device with its own identity. |\n| The Talkie local client key (P-256 signing, Keychain) | The Talkie protocol requires P-256 ECDSA. An X25519 key cannot sign. |\n| The `visit-test` key (`--identity`) | The test tool simulates a second device. |\n| Trust records: `gateway/gateway-clients.json`, `host/bridge-devices.json`, the companion trusted-device list, `visit/hosts.json`, `paired-hosts.json` | They hold peers' keys, not this machine's key. |\n\n## Why one key is safe\n\nAll roles use the same primitives: X25519 key agreement, HKDF-SHA256 with salt\n`lattices-bridge-v1`, an HMAC-SHA256 request signature, and ChaChaPoly for\nencrypted bodies. Reuse of one key across these roles does not let a peer\nreplay a message into another role:\n\n- Each signature covers the method, path, device id, timestamp, nonce and body\n  hash.\n- Each encrypted body uses a distinct AAD for its direction (`request`,\n  `response`, `visit`).\n- Each trust store is keyed by the peer's id and grants only its own scopes.\n  A gateway pairing does not grant companion access, and a companion pairing\n  does not grant gateway access.\n\nRole ids must stay distinct. The gateway client id comes from the identity.\nThe visit device id stays in `visit/hosts.json`. Do not merge these ids.\n\nThe fingerprint is the same in every view: the first 12 hex digits of SHA-256\nover the base64 public key, as `XXXX-XXXX-XXXX`. The Mac bridge fingerprint in\nCompanion settings and the visit code in Machines now show the same value.\n\n## Migration\n\nThe first process that needs the identity creates it from the old files. It\nselects the first key that it can read, in this order:\n\n1. `gateway/gateway-key.json`, only if `gateway/gateway-clients.json` lists at\n   least one client;\n2. `companion/bridge.key`;\n3. `host/bridge-key.json`;\n4. `gateway/gateway-key.json`;\n5. the key in `client.json`;\n6. `visit/device-key`.\n\nIf no file has a usable key, the process generates a new key. The id and name\ncome from `client.json` if it is valid. If not, the id is a new UUID and the\nname is the host name.\n\nThe order keeps the key that the most peers pin. A gateway key that no client\npinned has no value, so the companion key comes before it. The iPad cannot\nre-pair without a person at the device, so the companion key comes before the\nclient and visit keys.\n\nAfter the link succeeds, the process deletes the five old key files. It keeps\n`gateway/gateway-clients.json`. The Keychain item from old Mac builds is not\nread and not deleted.\n\n### Effect on peers\n\nPeers that pinned the adopted key continue to work. Peers that pinned another\nold key must re-pair once.\n\n| Machine | Kept | Re-pair |\n| --- | --- | --- |\n| Mac with no gateway clients | companion key: the iPad and visiting Macs continue | this Mac's own gateway pairings (`lats hosts pair`) and visit pairings |\n| Mac with gateway clients | gateway key: its gateway clients continue | the iPad, visiting Macs, and this Mac's own outgoing pairings |\n| Linux host (archie) with gateway clients | gateway key: its gateway clients continue | the iPad, Macs that visit the bridge, and its own outgoing pairings |\n| Linux host with no gateway clients | bridge key: the iPad and visiting Macs continue | its own outgoing pairings |\n\n### Mixed versions\n\nAn old build does not read `device.json`. If an old build runs after the\nmigration, it creates new per-role key files with new keys. Peers that pair\nwith that build pin keys that the new build deletes or ignores. Update the CLI,\nthe gateway and the app on a machine together, and restart all three.\n\n## Related\n\nLAT-014 (paired Macs) proposes a P-256 TLS identity in the Keychain. That\nproposal is not built. If it is built, it should explain why it cannot use the\ndevice identity."
    }
  ],
  "prompts": []
}
