Agent API

WebSocket API reference for programmatic control of lattices

The lattices menu bar app runs a WebSocket server on ws://127.0.0.1:9399. 35+ RPC methods and 5 real-time events.

Quick start

  1. Launch the server (it starts with the menu bar app):
lattices app
  1. Check that it’s running:
lattices daemon status
  1. Call a method from Node.js:
import { daemonCall } from '@lattices/cli'

const windows = await daemonCall('windows.list')
console.log(windows) // [{ wid, app, title, frame, ... }, ...]

Or from any language — it’s a standard WebSocket:

# Plain websocat example
echo '{"id":"1","method":"daemon.status"}' | websocat ws://127.0.0.1:9399

Wire protocol

lattices uses a JSON-RPC-style protocol over WebSocket on port 9399.

Request

{
  "id": "unique-string",
  "method": "windows.list",
  "params": { "wid": 1234 }
}
FieldTypeRequiredDescription
idstringyesCaller-chosen ID, echoed in response
methodstringyesMethod name (see below)
paramsobjectnoMethod-specific parameters

Response

{
  "id": "unique-string",
  "result": [ ... ],
  "error": null
}
FieldTypeDescription
idstringEchoed from request
resultany | nullMethod return value (null on error)
errorstring | nullError message (null on success)

Errors

ErrorMeaning
Unknown methodThe method string is not recognized
Missing parameterA required param was not provided
Not foundThe referenced resource doesn’t exist

Connection lifecycle

  • The server starts when the menu bar app launches and stops when it quits.
  • Connections are plain WebSocket. No handshake, no auth, no heartbeat.
  • The Node.js daemonCall() client opens a fresh connection per call and closes it when the response arrives. For event subscriptions, hold the connection open (see Reactive event pattern).
  • If the server restarts (e.g. after lattices app restart), existing connections are dropped. Clients should reconnect and treat it as stateless. There is no session resumption.

Node.js client

lattices ships a zero-dependency WebSocket client that works with Node.js 18+. It handles connection, framing, and request/response matching internally.

daemonCall(method, params?, timeoutMs?)

Send an RPC call and await the response.

import { daemonCall } from '@lattices/cli'

// Read-only
const status = await daemonCall('daemon.status')
const windows = await daemonCall('windows.list')
const win = await daemonCall('windows.get', { wid: 1234 })

// Mutations
await daemonCall('session.launch', { path: '/Users/you/dev/myapp' })
await daemonCall('window.tile', { session: 'myapp-a1b2c3', position: 'left' })

// Custom timeout (default: 3000ms)
await daemonCall('projects.scan', null, 10000)

Returns the result field from the response. Throws if the server returns an error, the connection fails, or the timeout is reached.

isDaemonRunning()

Check if the server is reachable.

import { isDaemonRunning } from '@lattices/cli'

if (await isDaemonRunning()) {
  console.log('daemon is up')
}

Returns true if daemon.status responds within 1 second.

Error handling

daemonCall throws on errors — always wrap calls in try/catch:

import { daemonCall } from '@lattices/cli'

try {
  await daemonCall('session.launch', { path: '/nonexistent' })
} catch (err) {
  // err.message is one of:
  //   "Not found"              — resource doesn't exist
  //   "Missing parameter: ..." — required param missing
  //   "Unknown method: ..."    — bad method name
  //   "Daemon request timed out" — no response within timeout
  //   ECONNREFUSED             — daemon not running
  console.error('Daemon error:', err.message)
}

System

MethodTypeDescription
daemon.statusreadHealth check and stats
api.schemareadFull API schema for self-discovery
diagnostics.listreadRecent diagnostic entries

daemon.status

Health check and basic stats.

Params: none

Returns:

{
  "uptime": 3600.5,
  "clientCount": 2,
  "version": "1.0.0",
  "windowCount": 12,
  "tmuxSessionCount": 3
}

api.schema

Return the full API schema including version, models, and method definitions. Useful for agent self-discovery.

Params: none

diagnostics.list

Return recent diagnostic log entries from the daemon.

Params:

FieldTypeRequiredDescription
limitnumbernoMax entries to return (default 50)

Windows & Spaces

MethodTypeDescription
windows.listreadAll visible windows
windows.getreadSingle window by ID
windows.searchreadSearch windows by query
spaces.listreadmacOS display spaces
window.tilewriteTile a window to a position
window.focuswriteFocus a window / switch Spaces
window.movewriteMove a window to another Space
window.assignLayerwriteTag a window to a layer
window.removeLayerwriteRemove a window’s layer tag
window.layerMapreadAll window→layer assignments
layout.distributewriteDistribute windows evenly

windows.list

List all visible windows tracked by the desktop model.

Params: none

Returns: array of window objects:

[
  {
    "wid": 1234,
    "app": "Terminal",
    "pid": 5678,
    "title": "[lattices:myapp-a1b2c3] zsh",
    "frame": { "x": 0, "y": 25, "w": 960, "h": 1050 },
    "spaceIds": [1],
    "isOnScreen": true,
    "latticesSession": "myapp-a1b2c3",
    "layerTag": "web"
  }
]

The latticesSession field is present only on windows that belong to a lattices session (matched via the [lattices:name] title tag).

The layerTag field is present when a window has been manually assigned to a layer via window.assignLayer.

windows.get

Get a single window by its CGWindowID.

Params:

FieldTypeRequiredDescription
widnumberyesCGWindowID

Returns: a single window object (same shape as windows.list items). Errors: Not found if the window ID doesn’t exist.

windows.search

Search 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.

Params:

FieldTypeRequiredDescription
querystringyesSearch query (matches title, app, session, OCR text)
ocrbooleannoInclude OCR text in search (default true)
limitnumbernoMax results (default 50)

Returns: array of window objects with additional search fields:

[
  {
    "wid": 265,
    "app": "iTerm2",
    "title": "✳ Claude Code",
    "matchSource": "ocr",
    "ocrSnippet": "…~/dev/vox StatusBarIconFolder…",
    "frame": { "x": 688, "y": 3, "w": 1720, "h": 720 },
    "isOnScreen": true
  }
]

matchSource values: "title", "app", "session", "ocr".

CLI usage:

# Basic search (uses windows.search)
lattices search vox

# Deep search — adds terminal tab/process inspection for ranking
lattices search vox --deep

# Pipeable output
lattices search vox --wid
lattices search vox --json

# Search + focus + tile in one step
lattices place vox right

spaces.list

List macOS display spaces (virtual desktops).

Params: none

Returns: array of display objects:

[
  {
    "displayIndex": 0,
    "displayId": "main",
    "currentSpaceId": 1,
    "spaces": [
      { "id": 1, "index": 0, "display": 0, "isCurrent": true },
      { "id": 2, "index": 1, "display": 0, "isCurrent": false }
    ]
  }
]

window.tile

Tile a session’s terminal window to a screen position.

Params:

FieldTypeRequiredDescription
sessionstringyesSession name
positionstringyesTile position (see below)

Positions: left, right, top, bottom, top-left, top-right, bottom-left, bottom-right, left-third, center-third, right-third, maximize, center

window.focus

Focus a window — bring it to front and switch Spaces if needed.

Params (one of):

FieldTypeRequiredDescription
widnumbernoCGWindowID (any window)
sessionstringnoSession name (lattices windows)

Provide either wid or session. If wid is given, it takes priority.

window.move

Move a session’s window to a different macOS Space.

Params:

FieldTypeRequiredDescription
sessionstringyesSession name
spaceIdnumberyesTarget Space ID (from spaces.list)

window.assignLayer

Manually tag a window to a layer. Tagged windows are raised and tiled when that layer activates, even if they aren’t declared in workspace.json.

Params:

FieldTypeRequiredDescription
widnumberyesCGWindowID
layerstringyesLayer ID to assign

window.removeLayer

Remove a window’s layer tag.

Params:

FieldTypeRequiredDescription
widnumberyesCGWindowID

window.layerMap

Return all current window→layer assignments.

Params: none

Returns:

{
  "1234": "web",
  "5678": "mobile"
}

Keys are CGWindowIDs (as strings), values are layer IDs.

layout.distribute

Distribute all visible lattices windows evenly across the screen.

Params: none


Sessions

MethodTypeDescription
tmux.sessionsreadLattices tmux sessions
tmux.inventoryreadAll sessions including orphans
session.launchwriteLaunch a project session
session.killwriteKill a session
session.detachwriteDetach clients from a session
session.syncwriteReconcile session to config
session.restartwriteRestart a pane’s process

All session methods require tmux to be installed.

tmux.sessions

List tmux sessions that belong to lattices.

Params: none

Returns: array of session objects:

[
  {
    "name": "myapp-a1b2c3",
    "windowCount": 1,
    "attached": true,
    "panes": [
      {
        "id": "%0",
        "windowIndex": 0,
        "windowName": "main",
        "title": "claude",
        "currentCommand": "claude",
        "pid": 9876,
        "isActive": true
      }
    ]
  }
]

tmux.inventory

List all tmux sessions including orphans (sessions not tracked by lattices).

Params: none

Returns:

{
  "all": [ ... ],
  "orphans": [ ... ]
}

Both arrays contain session objects (same shape as tmux.sessions).

session.launch

Launch a new tmux session for a project. If a session already exists, it will be reattached. The project must be in the scanned project list — call projects.list to check, or projects.scan to refresh.

Params:

FieldTypeRequiredDescription
pathstringyesAbsolute path to project directory

Returns: { "ok": true } Errors: Not found if the path isn’t in the scanned project list.

session.kill

Kill a tmux session by name.

Params:

FieldTypeRequiredDescription
namestringyesSession name

session.detach

Detach all clients from a session (keeps it running).

Params:

FieldTypeRequiredDescription
namestringyesSession name

session.sync

Reconcile a running session to match its declared .lattices.json config. Recreates missing panes, re-applies layout, restores labels, re-runs commands in idle panes.

Params:

FieldTypeRequiredDescription
pathstringyesAbsolute path to project directory

Errors: Not found if the path isn’t in the project list.

session.restart

Restart a specific pane’s process within a session.

Params:

FieldTypeRequiredDescription
pathstringyesAbsolute path to project directory
panestringnoPane name to restart (defaults to first pane)

Projects & Layers

MethodTypeDescription
projects.listreadDiscovered projects
projects.scanwriteRe-scan project directory
layers.listreadWorkspace layers and active index
layer.switchwriteSwitch workspace layer
group.launchwriteLaunch a tab group
group.killwriteKill a tab group

projects.list

List all discovered projects.

Params: none

Returns: array of project objects:

[
  {
    "path": "/Users/you/dev/myapp",
    "name": "myapp",
    "sessionName": "myapp-a1b2c3",
    "isRunning": true,
    "hasConfig": true,
    "paneCount": 2,
    "paneNames": ["claude", "server"],
    "devCommand": "pnpm dev",
    "packageManager": "pnpm"
  }
]

devCommand and packageManager are present only when detected.

projects.scan

Trigger a re-scan of the project directory. Useful after cloning a new repo or adding a .lattices.json config.

Params: none

layers.list

List configured workspace layers and the active index.

Params: none

Returns:

{
  "layers": [
    { "id": "web", "label": "Web", "index": 0, "projectCount": 2 },
    { "id": "mobile", "label": "Mobile", "index": 1, "projectCount": 2 }
  ],
  "active": 0
}

Returns empty layers array if no workspace config is loaded.

layer.switch

Switch the active workspace layer. Focuses and tiles all windows in the target layer, launches any projects that aren’t running yet, and posts a layer.switched event.

Params:

FieldTypeRequiredDescription
indexnumbernoLayer index (0-based)
namestringnoLayer ID or label

Provide either index or name. If both are given, name takes priority.

group.launch

Launch a tab group session.

Params:

FieldTypeRequiredDescription
idstringyesGroup ID

Errors: Not found if the group ID doesn’t match any configured group.

group.kill

Kill a tab group session.

Params:

FieldTypeRequiredDescription
idstringyesGroup ID

Processes & Terminals

MethodTypeDescription
processes.listreadRunning developer processes
processes.treereadProcess tree from a PID
terminals.listreadTerminal instances with processes
terminals.searchreadSearch terminals by criteria

processes.list

List running processes relevant to development (editors, servers, build tools).

Params:

FieldTypeRequiredDescription
commandstringnoFilter by command name substring

Returns: array of process objects:

[
  {
    "pid": 1234,
    "ppid": 567,
    "command": "node",
    "args": "server.js",
    "cwd": "/Users/you/dev/myapp",
    "tty": "/dev/ttys003",
    "tmuxSession": "myapp-a1b2c3",
    "tmuxPaneId": "%0"
  }
]

processes.tree

Get the process tree rooted at a given PID.

Params:

FieldTypeRequiredDescription
pidnumberyesRoot PID

Returns: array of process objects (same shape as processes.list).

terminals.list

List all discovered terminal instances with their processes, tabs, and tmux associations.

Params:

FieldTypeRequiredDescription
refreshbooleannoForce-refresh the terminal tab cache

Returns: array of terminal instance objects:

[
  {
    "tty": "/dev/ttys003",
    "app": "Terminal",
    "windowIndex": 0,
    "tabIndex": 0,
    "isActiveTab": true,
    "tabTitle": "myapp",
    "processes": [ ... ],
    "shellPid": 1234,
    "cwd": "/Users/you/dev/myapp",
    "tmuxSession": "myapp-a1b2c3",
    "tmuxPaneId": "%0",
    "hasClaude": true,
    "displayName": "Terminal — myapp"
  }
]

terminals.search

Search terminal instances by various criteria.

Params:

FieldTypeRequiredDescription
commandstringnoFilter by command name substring
cwdstringnoFilter by working directory substring
appstringnoFilter by terminal app name
sessionstringnoFilter by tmux session name
hasClaudebooleannoFilter to only Claude-running TTYs

Returns: filtered array of terminal instance objects (same shape as terminals.list).


OCR

MethodTypeDescription
ocr.snapshotreadCurrent OCR results for all visible windows
ocr.searchreadFull-text search across OCR history
ocr.historyreadOCR timeline for a specific window
ocr.scanwriteTrigger an immediate OCR scan

See Screen OCR for configuration, scan schedules, and storage details.

ocr.snapshot

Get the current in-memory OCR results for all visible windows.

Params: none

Returns: array of OCR result objects:

[
  {
    "wid": 1234,
    "app": "Terminal",
    "title": "zsh",
    "frame": { "x": 0, "y": 25, "w": 960, "h": 1050 },
    "fullText": "~/dev/myapp $ npm run dev\nready on port 3000",
    "blocks": [
      {
        "text": "~/dev/myapp $ npm run dev",
        "confidence": 0.95,
        "x": 0.02, "y": 0.05, "w": 0.6, "h": 0.04
      }
    ],
    "timestamp": 1709568000.0
  }
]

ocr.search

Full-text search across OCR history using SQLite FTS5.

Params:

FieldTypeRequiredDescription
querystringyesFTS5 search query
appstringnoFilter by application name
limitnumbernoMax results (default 50)
livebooleannoSearch live snapshot instead of history (default false)

FTS5 query examples: error, "build failed", error OR warning, npm AND dev, react*

ocr.history

Get the OCR timeline for a specific window, ordered by most recent first.

Params:

FieldTypeRequiredDescription
widnumberyesCGWindowID
limitnumbernoMax results (default 50)

ocr.scan

Trigger an immediate OCR scan of all visible windows, bypassing the periodic timer. Results available via ocr.snapshot once complete; an ocr.scanComplete event is broadcast to all clients.

Params: none


Events

Events are pushed to all connected WebSocket clients when state changes. They have no id field — listen for messages with an event field.

EventTrigger
windows.changedDesktop window list changes
tmux.changedSessions created, killed, or modified
layer.switchedActive workspace layer changes
ocr.scanCompleteOCR scan cycle finishes
processes.changedDeveloper processes start or stop

windows.changed

{ "event": "windows.changed", "data": { "windowCount": 12, "added": [1234], "removed": [5678] } }

tmux.changed

{ "event": "tmux.changed", "data": { "sessionCount": 3, "sessions": ["myapp-a1b2c3"] } }

layer.switched

{ "event": "layer.switched", "data": { "index": 1, "name": "mobile" } }

ocr.scanComplete

{ "event": "ocr.scanComplete", "data": { "windowCount": 12, "totalBlocks": 342 } }

processes.changed

{ "event": "processes.changed", "data": { "interestingCount": 5, "pids": [1234, 5678] } }

Agent integration

CLAUDE.md snippet

Add this to your project’s CLAUDE.md so any AI agent working in the project knows how to control the workspace:

## Workspace Control

This project uses lattices for workspace management. The daemon API
is available at ws://127.0.0.1:9399.

### Search (find windows)
- Search by content: `daemonCall('windows.search', { query: 'myproject' })`
  Returns windows with `matchSource` ("title", "app", "session", "ocr") and `ocrSnippet`
- Search terminals: `daemonCall('terminals.search', {})` — tabs, cwds, processes
- CLI: `lattices search myproject` or `lattices search myproject --deep`

### Actions
- Focus a window: `daemonCall('window.focus', { wid: 1234 })`
- Tile a window: `daemonCall('window.tile', { session: 'name', position: 'left' })`
- Launch a project: `daemonCall('session.launch', { path: '/absolute/path' })`
- Switch layer: `daemonCall('layer.switch', { name: 'web' })`
- CLI: `lattices place myproject left` (search + focus + tile in one step)

### Import
\```js
import { daemonCall } from '@lattices/cli'
\```

Multi-agent orchestration

An orchestrator agent can set up the full workspace for sub-agents:

import { daemonCall } from '@lattices/cli'

// Discover what's available
const projects = await daemonCall('projects.list')

// Launch the projects we need
await daemonCall('session.launch', { path: '/Users/you/dev/frontend' })
await daemonCall('session.launch', { path: '/Users/you/dev/api' })

// Tile them side by side
const sessions = await daemonCall('tmux.sessions')
const fe = sessions.find(s => s.name.startsWith('frontend'))
const api = sessions.find(s => s.name.startsWith('api'))

await daemonCall('window.tile', { session: fe.name, position: 'left' })
await daemonCall('window.tile', { session: api.name, position: 'right' })

Reactive event pattern

Subscribe to events to react to workspace changes:

import WebSocket from 'ws'

const ws = new WebSocket('ws://127.0.0.1:9399')

ws.on('message', (raw) => {
  const msg = JSON.parse(raw)

  if (msg.event === 'tmux.changed') {
    console.log('Sessions:', msg.data.sessions.join(', '))
  }

  if (msg.event === 'windows.changed') {
    console.log('Windows:', msg.data.windowCount, 'total')
  }

  if (msg.event === 'layer.switched') {
    console.log('Switched to layer', msg.data.index)
  }
})

// You can also send RPC calls on the same connection
ws.on('open', () => {
  ws.send(JSON.stringify({ id: '1', method: 'daemon.status' }))
})

Health check before use

Always verify the daemon is running before making calls:

import { isDaemonRunning, daemonCall } from '@lattices/cli'

if (!(await isDaemonRunning())) {
  console.error('lattices daemon is not running — start it with: lattices app')
  process.exit(1)
}

const status = await daemonCall('daemon.status')
console.log(`Daemon up for ${Math.round(status.uptime)}s, tracking ${status.windowCount} windows`)