# Lanes Documentation > Lanes is three documented products: Lanes Desktop, an agentic development environment for running AI coding agents in parallel; Lanes Forms, a form backend as a service you can provision with one call; and Lanes Link, one open-source MCP endpoint you run yourself to give every agent permissioned access to your accounts. This file contains the full documentation for all three, plus use case and comparison pages, as Markdown for coding agents. # Lanes Desktop # Quick Start > Get Lanes installed and your first AI session running in about two minutes. ## Install Lanes Install Lanes via Homebrew: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Requires macOS Ventura or later. Native on Apple Silicon and Intel. **Join our [Discord community](https://discord.gg/B3f8QjqeBa)** for updates, to share feedback, ask questions, and connect with other users. We're iterating fast and Discord is the best way to stay in the loop. ## First Launch The first time Lanes opens, the welcome screen offers four entry points: grant folder access, add a project, start a CLI session, or open a bare terminal. Pick the path that matches what you want to do first; you can always reach the others from the sidebar later. ![The refreshed Lanes welcome screen with the headline 'Welcome to Lanes' and the tagline 'The workspace for parallel AI coding', plus four cards: Folder access, Add a project, Start a CLI session, and Open a terminal](/assets/whats-new-v041/welcome-screen.png) If you start with **Add a project**, Lanes asks for the folder where your repos live and registers it as the default location for new sessions. You can add more projects, change the default, or set per-issue working directories at any time from **Settings > General**. ## Tour the Interface The main window has three areas: - **Sidebar** (left): Lists your projects, worktrees, and provides quick navigation. Collapse it with the toggle button at the top for more board space. - **Board** (center): A task board with columns representing workflow stages. This is where your issues live. - **Detail panel** (bottom): Opens when you select an issue. Shows the terminal on the left and issue details (notes, instructions, metadata) on the right. Drag the divider to resize either side. The status bar at the very bottom shows connection state, active session count, total tokens used, and estimated cost. ## Create Your First Issue Press **Cmd+N** (or Ctrl+N on Linux) to open the new issue dialog. Fill in: - **Title**: A short description of the task, e.g., "Add dark mode toggle to settings page" - **Description**: Optional context for the task - **Instructions**: The prompt that will be sent to the AI agent when a session starts - **CLI**: Which AI agent to use (coming soon) - **Working directory**: Defaults to your global setting; override it per issue if needed - **Worktree strategy**: Choose "Create" to give this issue its own isolated git branch, or "None" to work in the current directory Click **Create** to add the issue to your board. It will appear in the Backlog or Planning column. ## Start a Session With your issue selected on the board, look at the detail panel at the bottom. You will see two buttons for starting a session: - **Plan**: Starts the AI agent in plan mode. The agent will analyze the task and propose a plan before making any changes. Good for understanding the scope of work first. - **Implement**: Starts the agent in implementation mode. It will begin working on the task immediately. Click one of these buttons. Lanes will spawn a terminal session, attach it to your issue, and the AI agent will start running. You can watch its output in real time in the terminal panel. ## Interact with the Running Session While a session is running, you can: - **Type directly** in the terminal panel to send input to the agent - **Drag files** from your file manager onto the terminal to inject their paths - **Watch the status indicator** on the issue card; it shows whether the agent is busy, awaiting your input, or has stopped The issue card on the board updates in real time to reflect the session state. ## Stop a Session To stop a running session: - Click the **Stop** button in the detail panel toolbar - Or press **Cmd+D** to mark the issue as complete, which stops the session and (if a worktree was created) offers to clean up the worktree - Or press **Cmd+R** to send the issue to Review (without stopping the session) if it needs human checking before Done You can also right-click an issue card on the board and select **Stop Runtime** from the context menu. ## What's Next You now know the basics: create issues, start AI sessions, and watch them work on your board. From here, explore how the board organizes your work across multiple concurrent agents. Source: https://lanes.sh/docs/desktop/quick-start --- # The Issue Board > Your task workspace - the heart of Lanes where every issue has a visual home. The issue board is the heart of Lanes. It is the workspace where every issue, whether it is a planning note or a live AI session, has a visual home. ## Board Layout The board occupies the center of the Lanes window: - **Sidebar** (left): Project navigation, worktree list, and quick access to settings. Toggle it pinned open with the chevron, or leave it collapsed and let it expand on hover. Since v0.41, the project list slides out when your cursor lands on the sidebar and tucks away when it doesn't, so you keep board real estate without losing access. - **Workflow columns** (center): Vertical columns representing workflow stages. Issues appear as cards within these columns. - **Detail panel** (bottom): A resizable split view that shows the terminal on the left and issue details on the right. Click any issue card to open it here. ## Columns Each column represents a stage in your workflow: | Column | Purpose | |--------|---------| | **Backlog** | Issues waiting to be worked on. A holding area for future tasks. | | **Planning** | Issues being scoped or analyzed. Agents started in plan mode land here. | | **Implementation** | Active development. Agents doing the actual coding work live here. | | **Review** | Work that needs human review before it can be marked done. Press **Cmd+R** from Planning or Implementation to send issues here. | | **Done** | Completed issues. Use the column action menu to clear finished items. | | **Misc** | A catch-all for issues that do not fit the standard workflow. | Columns are collapsible. Click the column header chevron to collapse or expand a column, which is useful when you want to focus on just a few stages. ## Creating Issues Press **Cmd+N** (Ctrl+N on Linux) to open the new issue dialog. Every issue has: - A **title** (required) - An optional **description** for context - **Instructions** that get sent to the AI agent as the initial prompt - A **CLI** selection (Claude Code, Codex, Gemini, or a bare terminal) - A **working directory** and optional **worktree strategy** Issues start as drafts. Once you fill in the required fields and confirm, they appear on the board. ## Editing Issues Click any issue card to open it in the detail panel. From there you can: - **Edit the title** inline by clicking on it - **Edit the description and instructions** in the Issue tab on the right side of the detail panel - **View metadata and metrics** (tokens, cost, runtime) in the Meta tab All edits save automatically as you type. ## Moving Issues Between Columns There are two ways to move issues: 1. **Drag and drop**: Click and drag a card from one column to another. Drop it at the desired position within the target column. 2. **Right-click context menu**: Right-click a card and select **Move** to choose a target column from the submenu. Lanes preserves your custom sort order within each column. When you drop a card between two others, it stays where you put it. ## Multi-Select You can select multiple issues at once for bulk actions: - **Shift+Click**: Select a range of issues between the last selected card and the one you click. - **Cmd+Click** (Ctrl+Click on Linux): Toggle individual cards in or out of the selection. - **Cmd+A** (Ctrl+A): Select all issues in the focused column. Press it again to select all issues on the entire board. - **Escape**: Clear the current selection. When multiple issues are selected, a **bulk actions toolbar** appears with options to: - **Stop**: Stop all running sessions on the selected issues - **Remove**: Delete the selected issues from the board - **Complete**: Mark all selected issues as done ## Right-Click Context Menu Right-click any issue card to access: | Action | What it does | |--------|-------------| | **Move** | Move the issue to a specific column | | **Complete** | Mark the issue as done and stop its session | | **Stop Runtime** | Stop the running session without completing the issue | | **Remove** | Delete the issue from the board | | **Add Label** | Open the label picker to tag the issue | | **Add Dependency** | Link this issue to others it depends on | | **Move to Backlog** | Send the issue back to the Backlog column | ## Board Tabs Above the columns, you will find tabs that filter the board view: - **All**: Shows every issue across all projects and worktrees. - **Per-project**: One tab per registered project. Shows only issues belonging to that project. - **Per-worktree**: One tab per active worktree. Shows only issues running in that worktree. Tabs are a quick way to focus on a specific area of work without setting up filters. ## Sort Options Click the sort control in the board header to change the display order: | Sort | Behavior | |------|----------| | **Default** | Manual sort order (drag-and-drop position) | | **Newest** | Most recently created issues first | | **Updated** | Most recently modified issues first | | **Alphabetical** | Sorted A-Z by title | The chosen sort order applies across all columns. Source: https://lanes.sh/docs/desktop/issue-board --- # Working with Sessions > Sessions are the runtime engine of Lanes: real terminals running AI coding agents, attached to issues on your board. Run many in parallel on a single issue. Sessions are the runtime engine of Lanes. Each session is a real pseudo-terminal (PTY) running an AI coding agent, attached to an issue on your board. You can run multiple sessions in parallel, including several on the same issue, and watch them all from the board. ## What Is a Session? A session is a live terminal process, backed by a PTY, that runs an AI CLI like Claude Code, Codex CLI, or Gemini CLI. It has its own working directory, scrollback buffer, and lifecycle state. Sessions are always linked to an issue, which gives you a single place to track the task, its output, and its results. Sessions survive if you close the detail panel or switch focus to another issue. They keep running in the background, and you can reconnect at any time by clicking the issue card. ## Many Sessions Per Issue Since v0.41, an issue can host as many sessions as the work needs. Each session gets its own slot (`session-1`, `session-2`, and so on) and an editable name. Sessions share the issue's worktree branch but run independently, with separate transcripts, status, and metrics. ![The Lanes board showing the Planning column with an 'Implement new endpoints' issue expanded to reveal three nested sessions: plan, implement, and review](/assets/whats-new-v041/sessions-board-view.png) A few patterns this unlocks: - **Plan, implement, review on one ticket.** One session drafts the approach, the next executes it, a third audits the diff. Each stage keeps its own history instead of collapsing into one giant transcript. - **Compare two approaches.** Spawn a sibling to try a different prompt, model, or cut at the same problem. Pick the diff you like, drop the other. - **Park work in parallel.** A long-running investigation in one session, scratch work in another, nothing gets lost when you context-switch. The issue stays the unit of work; the sessions become the stages of it. ### Spawning a sibling session Start a new session from inside an existing one (the **+** in the session selector, or **Cmd+N** while focused on an issue that already has a session). Lanes treats the new session as a sibling on the same issue: no second ticket, no extra setup, same worktree. ### Naming sessions Each session has an inline-editable name. Rename `session-1` to `plan`, `session-2` to `implement`, and `session-3` to `review` so the selector reads as a workflow instead of a list of numbers. Names are saved per session and persist across restarts. ## Session Settings on Launch When you start a new session, the picker opens with a **Session Settings** flyout that puts Harness, Model, Effort, Gateway, and Flags in one place. A pill at the top of the new-session view shows the current selection at a glance (for example, `Claude Code / claude-opus-4-7 / max`); one click on it opens the rest. ![The Lanes new-session view with the Session Settings flyout open, showing CLI set to Claude Code, Model set to claude-opus-4-7, Effort set to max, and Flags set to None](/assets/whats-new-v041/session-settings-picker.png) Setting these upfront means the agent runs with the right configuration from the first message, instead of being reconfigured mid-prompt. | Setting | What it controls | |---------|-----------------| | **Harness** | Which agent CLI to launch (Claude Code or Codex), or a bare terminal. See [Harness](/docs/desktop/harness). | | **Model** | The model the CLI uses for this session. The picker lists models supported by the selected CLI, and the real tags on your daemon when a [local model](/docs/desktop/local-llms) is active. | | **Effort** | Reasoning effort or "thinking" budget where the CLI exposes it (for example, Claude Code's `max` setting). | | **Gateway** | Which [provider profile](/docs/desktop/gateway) this session's requests go to. `Default` uses the CLI's own provider. | | **Flags** | Additional CLI flags for this session only. These layer on top of the repo-wide flags from **Project Settings > CLI Flags**. | The repo defaults from **Settings > General** (CLI) and **Project Settings > CLI Flags** are pre-filled. Overrides here apply to the session you're about to launch and are captured in the side panel so you can see what each session actually ran with. ## Starting a Session Select an issue on the board and look at the detail panel at the bottom of the screen. You will see two start buttons: - **Plan**: Launches the agent in plan mode. It reads the issue instructions, analyzes the codebase, and proposes a plan without making changes. The issue moves to the Planning column. Use this when you want to review the approach first. - **Implement**: Launches the agent in implementation mode. It begins working on the task immediately, making code changes as needed. The issue moves to the Implementation column. Both modes send the issue's instructions as the initial prompt to the agent and respect the Session Settings flyout described above. ## Starting a Bare Terminal Press **Cmd+T** to open a new bare terminal session. This creates a plain terminal (not attached to an AI agent) that you can use for running tests, checking logs, or any other shell task. Bare terminals are also attached to an issue for tracking purposes, and they count as a session slot like any other. ## The Session Selector and Quick Actions Open the session selector from the detail panel header on any issue. The selector groups everything you can do on that issue in one menu: ![The session selector open on the Implement new endpoints issue showing three sessions named plan, implement, and review under a Sessions group, plus a Quick Actions group with test, commit, release, review-changes, add-tests, fix-lint, and refactor, alongside the issue's Context panel](/assets/whats-new-v041/sessions-and-quick-actions.png) - **Sessions** lists every session attached to the issue. Click one to focus it, or use the **+** to spawn a new sibling. - **Quick Actions** lists the [quick commands](/docs/desktop/quick-commands) configured for this repo plus your general ones: `test`, `commit`, `release`, `fix-lint`, deploy scripts, anything the project defines. Click one and it runs on the same worktree, attached to the same issue. Spawning a new session and running a quick command now share the same surface, so you don't have to jump between panels for either action. Worktree creation is also reachable from the same menu when the issue doesn't have one yet. ## The Terminal Panel The terminal panel occupies the left side of the detail view at the bottom of the screen. It is a fully functional terminal emulator with: - Real-time output streaming from the AI agent - A scrollback buffer (approximately 50KB) so you can review earlier output - Dynamic resizing: drag the divider between the terminal and the issue details panel to adjust the split The right side of the detail view shows the Issue tab (notes and instructions) and the Meta tab (session metrics like tokens, cost, and runtime duration). ## Interacting with the Terminal While a session is running, you can interact with it directly: - **Type in the terminal**: Your keystrokes are sent directly to the running process. Use this to answer agent questions, approve plans, or provide additional context. - **Drag and drop files**: Drag a file from your file manager (or the Lanes file browser) onto the terminal. Lanes injects the file path as text, which is useful for pointing the agent at specific files. ## Session Status Indicators Each issue card on the board shows the current session state with a visual indicator. When an issue has multiple sessions, the card aggregates them: the most active state wins, and the indicator counts how many sessions are in that state. | Status | Meaning | |--------|---------| | **Busy** | The agent is actively working: processing, writing code, or thinking. | | **Awaiting input** | The agent has paused and is waiting for you to provide input or approval. | | **Stopped** | The session was manually stopped. The issue remains on the board. | | **Exited** | The agent process exited on its own (finished its work or encountered an end condition). | | **Error** | The session encountered an error. Check the terminal output for details. | These indicators update in real time, so you can monitor several agents at a glance from the board. Status detection was rewritten in v0.40 and tightened again in v0.41. The orange "awaiting input" bell now gates on the status file and title-idle markers with a 5-second floor, so continuous title updates from the CLI no longer false-trigger it. If you saw flicker on earlier builds, that pattern is gone. ## Resuming Claude Sessions When you stop and restart a Claude Code session, Lanes automatically passes the `--resume` flag. This tells Claude Code to pick up where it left off, using the conversation history from the previous run. You do not lose context when you need to pause and restart. To re-attach a stopped session from outside the app (for example, from an MCP-driven workflow), use the `lanes_resume_session` tool on the [Lanes Desktop MCP](/docs/desktop/local-mcp) server, which is the programmatic equivalent of the **Resume Session** button in the UI. ## Session History The detail panel includes a History tab that shows a paginated transcript of the session. This is a JSONL-based viewer that records every message exchanged between you and the agent, along with tool calls and their results. Each session has its own history, so siblings on the same issue stay independent. Use it to review what the agent did after the fact, especially for long-running sessions or when you want to diff how two siblings approached the same problem. ## Stopping Sessions There are several ways to stop a running session: - **Stop button**: Click the stop button in the detail panel toolbar. Only stops the focused session. - **Right-click > Stop Runtime**: From the issue card context menu on the board. Stops every session on that issue. - **Cmd+D**: Marks the issue as complete, which stops all of its sessions automatically. - **Bulk stop**: Select multiple issues and use the bulk toolbar's Stop action. ## Sending to Review When you're done with planning or implementation and the work needs human review before being marked Done, press **Cmd+R** (or click the **Send to Review** button in the detail panel footer). This moves the issue to the Review column without stopping any sessions, keeping the agent's context alive in case the reviewer asks for changes. ## Completing an Issue When you mark an issue as complete (via Cmd+D, the Complete context menu item, or dragging to Done): 1. Every running session on the issue is stopped. 2. If the issue had a dedicated worktree, Lanes checks for uncommitted changes. If the worktree is clean, it is automatically removed. If it has uncommitted work, Lanes warns you before cleanup. 3. The issue moves to the Done column. You can always reopen a completed issue by dragging it back to an earlier column and starting a new session. Source: https://lanes.sh/docs/desktop/sessions --- # Worktree Management > Git worktrees give each issue its own isolated branch and working directory, so multiple AI agents can work in the same repo without collisions. Lanes uses git worktrees to give each issue its own isolated branch and working directory. This means multiple AI agents can work on different tasks in the same repository at the same time, without their changes colliding. ## What Are Git Worktrees? A git worktree is a separate checkout of your repository that shares the same `.git` history but has its own working directory and branch. Think of it as a lightweight clone that lives alongside your main checkout. Changes in one worktree do not affect another. Lanes leverages this feature so that when you have three agents working on three different issues in the same repo, each one operates in its own directory on its own branch. No merge conflicts mid-work, no accidental overwrites. ## Worktree Strategies When creating an issue, you choose a worktree strategy that controls how Lanes sets up the working environment: | Strategy | Behavior | |----------|----------| | **Default** | Uses your project-level setting (which defaults to "Create" for git repos). | | **None** | The session runs directly in the project's current directory. No worktree is created. Use this for quick tasks that do not need isolation. | | **Create** | Lanes automatically creates a new git worktree for this issue. The session runs inside that worktree with its own branch. | | **Select** | Opens a picker so you can choose an existing worktree. Useful when you want to continue work in a worktree that was created earlier. | ## How Auto-Creation Works When you choose the "Create" strategy (or leave it on "Default" for a git project), Lanes: 1. Creates a new directory under `.worktrees/` in your project root. 2. Names it using the pattern `{issueId}-{adjective}-{noun}`, for example, `407-fond-cave` or `312-bright-oak`. 3. Creates a new git branch based on the project's base branch (usually `main` or `develop`). 4. Points the session's working directory to this new worktree. The `.worktrees/` directory keeps all worktrees organized in one place. Add it to your `.gitignore` if you do not want it tracked. You can also create a worktree on demand from the session selector. Since v0.41, the **Quick Actions** group on any issue without a worktree includes a **Create Worktree** entry, so you can scaffold the working directory the same place you launch a session. ## The Worktree Status Popover The status bar at the bottom of the Lanes window includes a worktree indicator. Click it to open the worktree status popover, which shows: - All active worktrees grouped by project - **Dirty indicators** next to worktrees that have uncommitted changes - Quick navigation to jump to an issue associated with a worktree This gives you an at-a-glance view of which worktrees need attention. ## Dirty-State Warnings Lanes monitors worktrees for two kinds of uncommitted state: - **Uncommitted changes**: Modified, added, or deleted files that have not been committed yet. - **Unpushed commits**: Commits that exist on the worktree branch but have not been pushed to the remote or merged into the base branch. When either condition is detected, the worktree is marked as "dirty" in the status popover. This matters most during cleanup; Lanes will warn you before removing a dirty worktree so you do not lose work. ## Running Dev Servers and Tests Per Worktree Because each worktree is an independent directory on its own branch, you can run a full dev server or test suite inside it without affecting any other worktree. Combined with Lanes quick commands, this makes it straightforward to test a feature branch end-to-end while other agents continue working in parallel. [Watch the video](https://www.youtube.com/watch?v=g1zEcugO5cg) ### Quick commands run inside the worktree When you configure a quick command for a project, Lanes executes it with the worktree's directory as the working directory. This means: - `npm test` or `pnpm test` runs against that branch's code only - A dev server started from within the worktree serves that branch's version of the app - Any environment variables or config files in the worktree are local to it You can bind a start-and-test command directly to **Test Worktree** in **Settings > Projects** so that one click spins up and validates the right environment. ### Avoiding port conflicts If you run a dev server in multiple worktrees at the same time, they need different ports. A simple approach is to derive a port from the worktree directory name, which is unique per issue: ```bash PORT_OFFSET=$(echo "$PWD" | cksum | awk '{print $1 % 1000}') PORT=$((3000 + PORT_OFFSET)) PORT=$PORT npm run dev ``` For multi-service setups (Docker Compose and similar), pass the same variable through to each service's port mapping so the entire stack shifts together without conflicts. ## Finishing Work in a Worktree When your AI agent is done working in a worktree, Lanes provides two built-in quick commands to wrap things up. You will find these in the terminal side panel as action buttons when the issue has a worktree attached. **Test Worktree**: Runs your project's test suite inside the worktree. Use this to verify the agent's changes before merging. It is a good habit to run this as soon as the agent signals it is done, before moving on to the merge step. **Complete & Merge**: Commits any remaining changes, merges the worktree branch into the base branch, and completes the issue. This is the final step that brings the agent's work back into your main codebase. The recommended flow is: 1. The agent finishes its work and the session becomes idle or stops. 2. Click **Test Worktree** to validate the changes. 3. If tests pass, click **Complete & Merge** to merge and clean up. 4. If tests fail, restart the session or intervene manually to fix the issues, then repeat. These commands are also available as keyboard shortcuts (Cmd+Alt and the command's position number) and from the sidebar quick command picker. ## Auto-Cleanup on Issue Completion When you complete an issue that has a dedicated worktree (either via Complete & Merge or manually): 1. Lanes checks whether the worktree is clean (no uncommitted changes, no unpushed commits). 2. If the worktree is **clean**, it is automatically removed. The branch and directory are deleted. 3. If the worktree is **dirty**, Lanes shows a warning dialog explaining what uncommitted state exists. You can choose to remove it anyway or keep it for manual cleanup. This keeps your project directory from filling up with stale worktrees over time. ## Per-Project Base Branch Each project in Lanes has a base branch that new worktree branches are created from. Lanes auto-detects this (usually `main` or `master`), but you can override it: 1. Open **Settings**. 2. Find the project in the project list. 3. Change the **Base branch** field to your preferred branch (e.g., `develop`). All new worktrees for that project will branch off the configured base branch. ## Worktree Board Tabs The board tabs above the workflow columns include per-worktree tabs. Clicking a worktree tab filters the board to show only issues that are running in that specific worktree. This is helpful when you have many issues across multiple worktrees and want to focus on one area of work. You can combine worktree tabs with label filters and other filtering options for even more focused views. ## Tips - **Use worktrees for anything non-trivial.** If the agent will be editing files, give it an isolated worktree. Reserve "None" for read-only tasks like code review or research. - **Check the status popover regularly.** It is the fastest way to spot worktrees that need commits pushed or changes committed before you forget. - **Let auto-cleanup do its job.** If you commit and push your changes before completing an issue, the worktree will be removed automatically with no prompts. Source: https://lanes.sh/docs/desktop/worktrees --- # Labels & Filtering > Categorize issues with color-coded labels and use targeted filters to keep a busy board organized. Labels and filters help you organize a busy board. As you scale up to dozens of concurrent issues, color-coded labels and targeted filters keep everything findable. ## Creating Labels There are two ways to create a label: 1. **From Settings**: Open Settings and navigate to the **Labels** tab. Type a name, pick a color, and click the add button. 2. **From the board**: Right-click any issue card, select **Add Label**, and use the inline label creator at the bottom of the picker. Type a name, choose a color, and confirm. Labels have a maximum name length of 30 characters. Labels are **per-repository**, stored in `.lanes/labels.json` at the root of the repo. Commit that file to share a label taxonomy with your team, or add `.lanes/` to your `.gitignore` if you want labels to stay personal. Renames cascade atomically across every issue in the project. ## Available Colors Lanes offers 13 label colors: | Color | Example use | |-------|-------------| | Red | Bugs, critical issues | | Rose | Design tasks | | Pink | UI work | | Violet | Experiments | | Indigo | Enhancements | | Blue | Features | | Sky | Documentation | | Teal | Testing | | Emerald | Performance | | Amber | Warnings, tech debt | | Stone | Infrastructure | | Slate | Maintenance | | Gray | Low priority, miscellaneous | These are suggestions only. Use whatever color scheme works for your team or project. ## Assigning Labels to Issues You can assign labels from two places: - **Issue detail panel**: Select an issue on the board. In the detail panel, click the label area to open the label picker. Check one or more labels to assign them. - **Right-click context menu**: Right-click an issue card on the board and select **Add Label**. The label picker appears as a popover where you can toggle labels on and off. Issues can have multiple labels. Each label appears as a small colored badge on the issue card in the board view. ## Filtering by Labels To filter the board to show only issues with specific labels: 1. Click the **filter icon** in the board header (near the sort controls). 2. Select one or more labels from the label filter dropdown. 3. The board immediately updates to show only issues that have at least one of the selected labels. When a label filter is active, a **filter badge** appears in the board header showing the count of active filters. This makes it obvious when you are looking at a filtered view rather than the full board. ## Filtering by Working Directory You can also filter the board by working directory: 1. Open the filter controls in the board header. 2. Select one or more working directories from the directory filter. 3. Only issues with matching working directories are shown. This is useful when you have issues spread across multiple repositories or project folders. ## Filtering by Workflow Step The step filter lets you show or hide issues based on their current column: 1. Open the filter controls. 2. Check or uncheck workflow steps (Planning, Implementation, Review, Done, etc.). 3. The board shows only issues in the selected steps. By default, Done issues are filtered out to keep the board focused on active work. ## The Filter Badge When any filters are active, a badge appears next to the filter icon in the board header. The badge shows the total number of active filter criteria. For example, if you have two labels selected and one directory selected, the badge shows "3". This is your reminder that the board is not showing everything. ## Clearing All Filters To reset the board to its unfiltered state, click the **Clear all** button in the filter panel. This removes all label, directory, and step filters at once, restoring the full board view. ## Combining Filters Filters stack together: - **Within a category** (e.g., multiple labels), filters use OR logic; an issue matches if it has any of the selected labels. - **Across categories** (e.g., labels + directories), filters use AND logic; an issue must match at least one label AND at least one directory to appear. For example, selecting the "Bug" and "Feature" labels plus a specific working directory shows all bugs and features in that directory. ## Sort Order Sort order works independently of filters. You can sort the filtered results by: | Sort | Behavior | |------|----------| | **Default** | Manual drag-and-drop order | | **Newest** | Most recently created first | | **Updated** | Most recently modified first | | **Alphabetical** | A-Z by title | Change the sort order from the sort control in the board header. The sort applies to all visible columns. Source: https://lanes.sh/docs/desktop/labels-and-filtering --- # Dependencies > Link issues that block each other so Lanes works through them in the correct order. Dependencies let you link issues that block each other. When issue B depends on issue A, Lanes knows that B cannot proceed until A is done. This keeps your agents from starting work that relies on unfinished prerequisites. ## What Dependencies Are A dependency is a directional link between two issues. It says: "this issue cannot be worked on until that issue is complete." For example: - "Add API endpoint" must be done before "Build frontend form that calls the API" - "Set up database migrations" must be done before "Implement user authentication" Dependencies are one-to-many. An issue can depend on multiple other issues, and multiple issues can depend on the same prerequisite. ## Adding Dependencies There are two ways to add a dependency: ### From the Context Menu 1. Right-click an issue card on the board. 2. Select **Add Dependency** from the context menu. 3. The dependency picker dialog opens, showing all issues in your board. 4. Check one or more issues that this issue depends on. 5. Confirm your selection. ### From the Issue Detail Panel 1. Click an issue to open it in the detail panel. 2. In the issue details on the right side, find the dependencies section. 3. Click to open the dependency picker. 4. Select the prerequisite issues and confirm. ## The Dependency Picker The dependency picker is a multi-select dialog that lists all issues in your workspace. You can: - Scroll through the full list of issues - Check multiple issues to add several dependencies at once - Uncheck issues to remove existing dependencies The picker shows issue titles and their current workflow status so you can make informed choices. ## Removing Dependencies To remove a dependency, open the dependency picker for the issue (via context menu or detail panel) and uncheck the issue you want to unlink. The dependency is removed immediately. ## Cycle Detection Lanes automatically prevents circular dependency chains. If issue A depends on B, and B depends on C, you cannot make C depend on A. The cycle detection runs whenever you try to add a dependency, and it will block the addition if it would create a loop. This guarantees that your dependency graph is always a directed acyclic graph (DAG), which means there is always a valid order in which issues can be completed. ## Visual Dependency Badges Issue cards on the board display a small dependency badge when they have dependencies. The badge shows the number of issues that this card depends on. For example, a badge showing "2" means the issue is waiting on two other issues. This gives you a quick visual signal when scanning the board; cards with dependency badges may be blocked and waiting for prerequisite work to finish. ## How Blocking Works An issue with unfinished dependencies is considered **blocked**. Here is how blocking behaves: - A dependent issue stays blocked until **all** of its prerequisite issues reach the Done column. - Blocked issues show a distinct visual state on the board so you can tell at a glance which issues are waiting. - You can still manually drag a blocked issue between columns if you want to override the blocking behavior. - Once all prerequisites are complete, the issue is automatically unblocked. For example, if issue C depends on both A and B, then C remains blocked until both A and B are in the Done column. ## Dependencies and Automation Dependencies integrate with Lanes' automation features: - **Queue manager**: When the queue manager auto-fills session slots from the backlog, it skips blocked issues. Only unblocked issues are eligible for automatic pickup. - **Auto-pickup**: Blocked issues are not picked up for automatic session creation, even if they are marked as ready. They wait until their dependencies are resolved. This means you can set up a chain of dependent issues in the backlog, and Lanes will work through them in the correct order automatically. As each issue completes and unblocks the next, the queue manager picks up the newly unblocked work. ## Tips - **Keep dependency chains short.** Long chains (A > B > C > D > E) create bottlenecks. If possible, structure work so that issues can run in parallel. - **Use dependencies for real blockers only.** If two tasks are merely related but can be done in any order, do not add a dependency. Labels or descriptions are better for tracking loose relationships. - **Check the board for blocked issues.** After completing a major prerequisite, glance at the board to see what got unblocked. If automation is enabled, those issues may already be starting up. Source: https://lanes.sh/docs/desktop/dependencies --- # Quick Commands > Pre-configured prompts and shell commands you can execute with a single click or keystroke. Quick commands are pre-configured prompts or shell commands that you can execute with a single click or keystroke. They let you automate repetitive actions -- committing code, running tests, linting -- without typing the same instructions over and over. ## Built-in Commands Lanes ships with a set of default quick commands so you can be productive immediately: | Command | What it does | |---------|-------------| | **Commit** | Asks Claude Code to commit the current changes | | **Release** | Walks through versioning and release prep | | **Review Changes** | Reviews uncommitted work against the base branch | | **Add Tests** | Generates tests for recent changes | | **Fix Lint** | Finds and fixes linting errors | | **Refactor** | Suggests and applies refactoring improvements | | **Test Worktree** | Runs the project test suite inside the worktree | | **Complete & Merge** | Finishes the issue and merges the worktree branch | You can modify or remove any of these, and add your own. ## Command Types Every quick command has a **type** that determines how it runs: - **Claude** -- Sends a prompt into the active Claude Code session. The prompt is injected into the terminal as if you typed it, so Claude Code picks it up and acts on it. Use this for anything you would normally ask an AI agent to do. - **Terminal** -- Executes a raw shell command directly in the session terminal. Use this for deterministic scripts like `npm test`, `cargo build`, or `git push`. ## Command Categories Commands are also grouped into two **categories** that control where they appear: - **General** -- Available everywhere. These show up in the quick-command menu regardless of whether the issue has a worktree. Good for universal actions like "Commit" or "Fix Lint." - **Worktree** -- Only shown when the selected issue has an active worktree. These are for operations that only make sense in a worktree context, like "Test Worktree" or "Complete & Merge." ## Running Quick Commands There are three ways to fire a quick command: ### From the Sidebar Click the lightning bolt icon in the sidebar to open the quick-command menu. You will see a list of available commands filtered by the current context (general commands always appear; worktree commands appear only when relevant). Click one to run it. When you run a quick command from the sidebar without an issue selected, Lanes creates a new **misc issue** to host the session. This keeps your board organized -- every command execution is tracked against an issue. ### From the Session Selector Since v0.41, the session selector on any issue doubles as the launching point for quick commands. Open it from the detail panel header and you will see a **Quick Actions** group below the issue's sessions. Whatever you've configured for this worktree (plus your general ones) lives there: `test`, `commit`, `release`, `fix-lint`, and so on. Running a quick action from here keeps it attached to the issue you're already looking at, on the same worktree, in a sibling session. No terminal hop, no separate panel. Worktree creation is reachable from the same menu when the issue doesn't have one yet, so the "scaffold a worktree" thought is the same shape as the "spawn a session" or "run a quick action" thought. ### With Keyboard Shortcuts Press **Cmd+Alt+1** through **Cmd+Alt+9** to run the first through ninth quick command by position. The numbering matches the order in your quick-command list, so you can reorder them to put your most-used commands on the easiest shortcuts. ## Creating Custom Quick Commands 1. Open **Settings** (Cmd+, or the gear icon in the sidebar). 2. Switch to the **Quick Actions** tab. 3. Click the add button to create a new command. 4. Fill in the fields: - **Name** -- A short label that appears in the menu (e.g., "Deploy Staging"). - **Prompt / Command** -- The text to send. For Claude-type commands this is the prompt; for Terminal-type commands this is the shell command. - **Type** -- Choose "Claude" or "Terminal." - **Category** -- Choose "General" or "Worktree." 5. Save your changes. ## Where Quick Commands Are Stored Quick commands are **per-repository**. They live in a plain JSON file at `.lanes/quick-actions.json` in the root of the repo, alongside the rest of your per-project config (labels, CLI flags, base branch). This means a monorepo can have a `pnpm test` quick command without forcing the same on a Rust side project, and your context menu stops growing into the union of every project's preferences. You get to choose how it is shared: - **Commit `.lanes/`** to share quick commands with your team, the same way you would share `.vscode/` or `.editorconfig`. - **Add `.lanes/` to `.gitignore`** if you want personal quick commands that do not bleed into the repo. ## Editing and Reordering From the same Quick Actions tab in Settings, you can: - **Edit** any command by clicking on it and changing its fields. - **Reorder** commands by dragging them up or down in the list. The order here determines both the menu order and which keyboard shortcut (Cmd+Alt+1-9) maps to which command. - **Delete** a command you no longer need. Changes take effect immediately -- no restart required. ## Tips - Combine Claude-type commands with detailed prompts for repeatable AI workflows. For example, a "Write Changelog" command could include specific formatting instructions. - Use Terminal-type commands for build scripts, deployment commands, or anything that does not need AI involvement. - Keep your most-used commands in positions 1-9 so the keyboard shortcuts stay convenient. Source: https://lanes.sh/docs/desktop/quick-commands --- # File Browser & Editor > Browse and edit project files without leaving Lanes, using a built-in Monaco editor. Lanes includes a built-in file browser and code editor so you can view and edit project files without leaving the app. This is especially useful when you want to inspect what an AI agent changed, make a quick fix, or browse your codebase alongside the board. ## Adding Projects to the Sidebar Before you can browse files, Lanes needs to know which directories to show. - Click the **+** button in the sidebar to add a project folder, or - Open **Settings > General** and manage your working folders from there. You can add multiple project directories. Each one appears as a top-level entry in the sidebar file tree. ## The File Tree The sidebar shows an expandable file tree for each project you have added. Folders can be expanded and collapsed by clicking the arrow next to them. The tree is **lazy-loaded** -- child entries are fetched only when you expand a folder, so even large projects stay responsive. ### Default-Collapsed Directories Some directories are collapsed by default because they are typically not useful to browse manually: - `node_modules` - `.git` - `target` - `dist` You can still expand them if you need to; they just start closed to keep the tree manageable. ## Navigating Files and Folders Click any folder to expand or collapse it. Click any file to open it in the **Working Folder** pane, which puts a folder tree on the left and the editor on the right. The tree highlights the currently open file so you can see where you are, and both the expanded folders and the open file are remembered per project. You can also open the pane directly: right-click a project in the sidebar and choose **Working Folder**, or use the button in the app header. Drag the divider to give either side more room. ## The Monaco Editor When you open a file, it appears in a full-featured Monaco editor -- the same editor engine that powers VS Code. You get: - **Syntax highlighting** for all common languages and file types. - **Line numbers** in the gutter for easy reference. - **Theme matching** -- the editor follows your app theme (light or dark), so it blends naturally with the rest of Lanes. ## One File at a Time The pane holds **one open file per project**, not a row of tabs. Clicking another file in the tree replaces what is in the editor, and Lanes remembers which file you had open the next time you come back to that project. Close the current file with the **X** in the editor header to get the empty state back. That keeps the pane honest about what it is for: reading and fixing the file in front of you, next to a running agent. For a session with a dozen files open at once, hand the folder to your IDE with **Open in** (see below). ## Editing and Saving Editing is on by default. The header button toggles between **Edit** and **View Mode**, so you can put the file into a read-only state when you only want to look. When you have unsaved changes, a **Save** button appears in the header. **Cmd+S** does the same thing and works even when the editor does not have focus, so saving never depends on where you last clicked. ## Markdown Files Markdown gets three view modes, chosen from a control in the header: - **Code** -- the raw markdown source. - **Split** -- source and rendered output side by side. - **Preview** -- rendered output only. Your choice persists across files. Two toggles sit next to it: **Soft wrap** for long lines, and **Overview (minimap)** for the map down the right edge. ## Files Lanes Will Not Open Two files never open in the editor, and it says why rather than freezing: - **Anything over 5 MB.** Minified bundles, large logs, and vendored data would stall the editor, so Lanes reports the size instead of loading it. Use **Open in** for those. - **Binary files.** There is nothing useful to show, so Lanes says so. If the open file is deleted or moved while you are looking at it, the editor notices and closes it rather than leaving a stale buffer you could save over the top of something else. ## Open in Your Editor When you want to take the wheel from the agent, the working folder has an **Open in** menu that hands the current directory to a full IDE in one click. Lanes detects which editors are installed on your machine and only lists those. ![The Lanes Working Folder with the Open in menu open, listing the editors detected on the machine](/assets/deep-link/open-in.png) Supported editors: - **VS Code** - **Cursor** - **Zed** - **JetBrains** suite (IntelliJ IDEA, WebStorm, PyCharm, RubyMine, GoLand, PhpStorm, CLion, Rider, RustRover, DataGrip, AppCode, Android Studio) - **Sublime Text** - **Xcode** - **Windsurf** - **Fleet** The menu opens the exact folder the session is running in, including the worktree path when an issue has one. No path copying, no terminal hop. ## Right-Click in the Tree The tree's context menu covers the file operations you would otherwise drop to a terminal for: - **New File** and **New Folder**, created inside the folder you clicked. - **Add File...**, which brings existing files in from anywhere on disk. - **Rename**, edited in place in the tree. - **Delete**, which asks first. - **Copy Path**, for pasting a path into a prompt. - **Open in**, to hand that one file to an external editor. You can also drag files between folders in the tree to move them. ## Tips - Use the file browser alongside agent sessions to review changes as the AI makes them -- open the terminal in one panel and the edited file in another. - The editor is great for quick tweaks. For heavy editing sessions, you might still prefer your full IDE, but for inspecting diffs and making small fixes Lanes has you covered. - Drag a file from the file tree onto an open terminal to inject its path. Handy when you want to tell an agent to look at a specific file. Source: https://lanes.sh/docs/desktop/file-browser --- # Databases Explorer > Browse SQLite databases and run read-only SQL queries without leaving Lanes. Lanes includes a built-in Databases Explorer so you can inspect SQLite databases in your project -- browse tables, peek at rows, and run read-only SQL queries -- without leaving the app or reaching for a separate database tool. It lives in the context sidepanel, right alongside the [File Browser](/docs/desktop/file-browser). ![The Databases Explorer showing the table list on the left, a SQL query in the editor, and the results grid below](/assets/whats-new-v042/databases-explorer.png) ## Opening the Databases Explorer Open the Databases Explorer from the context sidepanel toolbar at the top of an issue -- click the **Databases** icon (the database symbol) next to Context, Changes, and Working Folder. You can also reach it from a project's menu in the sidebar. It opens as a full-width panel. ![The Databases entry in the project menu, with the context sidepanel toolbar icons highlighted](/assets/whats-new-v042/databases-open.png) The explorer works against the **working folder** linked to the current issue. If no folder is linked yet, you'll see "No working folder linked" -- add or attach a project folder first (see the [File Browser](/docs/desktop/file-browser) docs). ## Finding Your Databases When you open the explorer, Lanes automatically scans the working folder for SQLite database files so you don't have to hunt for them. - It looks for common SQLite extensions: `.db`, `.sqlite`, `.sqlite3`, and `.db3`. - It searches subfolders too, while skipping heavy directories like `node_modules`, `.git`, `target`, `dist`, `build`, `.next`, and `.venv`. - Throwaway SQLite sidecar files (`-wal`, `-shm`, `-journal`) are ignored. The **Database** picker shows how many databases were found -- for example `Database (5)` -- and the path of the one you're viewing. While the scan runs it shows "Scanning…", and if nothing turns up it shows "No database found". Click **Rescan databases** any time to pick up files added after you opened the panel (for instance, a database an agent just created). ## Browsing Tables and Views Once a database is selected, its tables and views are listed on the left under **Tables**, with a count next to the heading. Tables and views have distinct icons, and internal `sqlite_*` system tables are hidden to keep the list focused on your data. Use the refresh button to reload the list after the schema changes. Click any table or view to preview it -- Lanes runs `SELECT * FROM "" LIMIT 100` for you and drops the first rows straight into the results grid, so a single click is enough to see what's inside. If a file can't be read, the explorer tells you why (for example, "Unable to open database: it is encrypted or not a valid SQLite database"). ## Running SQL Queries Above the results is a full SQL editor powered by Monaco -- the same editor engine used in the [File Browser](/docs/desktop/file-browser) -- with syntax highlighting, line numbers, and theme matching. Type any read-only statement (`SELECT`, `PRAGMA`, `EXPLAIN`, and so on) and run it with: - **⌘↵** (Ctrl+↵ on Windows/Linux), or - the green **Run** button. Drag the handle below the editor to give yourself more room for a longer query. ## Reading Results Results appear in a grid beneath the editor with sticky column headers that stay in place as you scroll. A status line summarizes each run -- the number of rows returned, or the SQL error if something went wrong. Cells render naturally: `NULL` values show in muted italics and booleans as `true`/`false`. Hover a long, truncated cell to see its full contents in a popover. To stay responsive, results are capped (500 rows by default). When a query returns more, Lanes appends a note that the result was truncated, so you always know you're seeing a partial set. Statements that return nothing show "Statement executed. No rows returned." ## Read-Only by Design The Databases Explorer opens every database in **read-only** mode. You can browse and query freely, but writes -- `INSERT`, `UPDATE`, `DELETE`, `ALTER TABLE`, and the like -- are rejected. That makes it safe to poke around a live database (including one an agent is actively using) without any risk of changing or corrupting your data. ## Switching Databases and Projects If your project has more than one database, click the **Database** picker to switch between them; the active one is marked with a checkmark. When you work across multiple project folders, the **Current Repository** picker lets you jump to another folder's databases without leaving the panel. ## Tips - Pair the explorer with an agent session to verify what the agent actually wrote -- run a quick `SELECT` to confirm a seed script populated the tables or a migration landed as expected. - Use `PRAGMA table_info("")` to inspect a table's columns and types. - Because it's read-only, it's a safe way to explore an unfamiliar database: list the tables, preview a few rows, and build a mental model before touching anything. - It shares the pane with the rest of the review tools. Read the migration in the [editor](/docs/desktop/file-browser), check the diff in [Changes](/docs/desktop/git-integration), then confirm the result here, without opening a separate database client. Source: https://lanes.sh/docs/desktop/databases --- # Git Integration > Review, commit, sync, and switch branches inside Lanes. A full git client next to your agent, not just a diff viewer. The **Changes** pane is a working git client. You can read what an agent did, commit it, publish or pull the branch, discard what you do not want, switch branches, and walk the history, without leaving Lanes or opening a second app. If you keep GitHub Desktop open beside your terminal, this is the panel that replaces it. ## Opening the Changes Pane Three ways in: - The **Changes** button in the app header. - Right-click a project in the sidebar and choose **Changes**. - Click the **Changes (n)** section, or the ahead/behind badges, in an issue's side panel. The pane opens over the right edge of the window. Use the dock button in its header to pin it as a column instead, so it stays open while you work in the terminal. ## The Header: Repository, Branch, and Sync Three controls across the top: - **Current Repository** picks which linked project you are looking at. It only becomes clickable once more than one folder is linked. - **Current Branch** opens the branch picker (see below). - The **sync button** does whatever the branch needs next, and says so: **Publish branch** for a branch with no upstream, **Pull changes**, **Push changes**, **Pull and Push** when both directions are behind, or **Up to date** when there is nothing to do. Arrow pills show the ahead and behind counts. Lanes fetches in the background about once a minute, so those counts reflect the remote without you asking. If a pull hits conflicts, Lanes says so plainly and leaves the resolution to git rather than half-finishing it. When the pane is scoped to an issue's worktree, a banner names the worktree and its base branch, and the repository and branch controls are locked. That is deliberate: switching branches under a running agent is how work ends up on the wrong one. ## The File List The left column lists every file with uncommitted or untracked changes. Each row carries: - **A change badge** -- **M** modified, **A** added, **D** deleted, **R** renamed. - **Line counts** -- added and removed, so you can see the size of a change before opening it. Binary files are marked instead of counted. Above the list, a filter box narrows a long list by path. Drag the divider to resize the column. The list refreshes every couple of seconds while the pane is open, so an agent's work appears as it happens. Right-click any file for **Discard Changes**, **Copy**, **Copy Path**, or **Open in** to hand the file to VS Code, Cursor, Zed, or another external editor. ## Viewing Diffs Click a file to open its diff. It is the Monaco diff editor, the same one VS Code uses, with syntax highlighting for the file's language and unchanged regions collapsed so you are reading changes rather than scrolling past context. The diff is **inline**, one column with additions and removals marked in place, and it is read-only. Edit the file in the [editor](/docs/desktop/file-browser) instead. What it compares against depends on scope. In a plain repository, the file is compared against the last commit. When the pane is scoped to an issue's worktree, it is compared against that worktree's **base branch**, so you see the whole change to that file rather than only the part that is not committed yet. A file that does not exist in the comparison point says so instead of showing every line as new. ## Committing The commit box sits under the file list, with a **Summary (required)** field and an optional **Description**. The button names its target, for example **Commit to feature/thing**, so you cannot commit to a branch you thought you had left. One thing to know: **committing stages everything.** Lanes runs the equivalent of `git add -A` and then commits, so every change in the working tree goes in, not just the file you were looking at. There is no staging area in the pane and no per-file or per-hunk selection. If you need a partial commit, discard what you do not want first, or do that one from a terminal. ## Discarding Changes **Discard Changes** on a file's context menu reverts that file. **Discard All** in the action row reverts the whole working tree. Both ask first, because both are unrecoverable. ## Switching Branches **Current Branch** opens a picker grouped into **Current branch**, **Default branch**, **Worktrees**, and **Branches**, with local-only branches marked as such and a filter box for long lists. - **New branch** creates one, letting you choose the ref to branch from, then switches to it. - **Set as my base** marks a branch as this project's base, which is what diffs and worktrees compare against. - **Delete branch** removes a local branch. If the working tree is dirty when you switch, Lanes asks what to do with the changes rather than refusing or clobbering them: **bring them** to the new branch, **stash them** on the current one, or **keep them** where they are. The **Move** button in the action row is the same choice, started from the changes instead of the branch. ## History The **History** tab lists recent commits with author, relative time, and any tags. Commits that have not been pushed are marked, so you can tell what is still local. Click a commit to see the files it touched and each file's diff, scoped to that commit alone. That is the view for reviewing an agent's work step by step rather than as one cumulative blob. On the most recent commit, and only while it is unpushed, right-click offers **Uncommit**, which keeps the changes and undoes the commit, and **Discard commit**, which throws both away. Neither is offered on a commit that already exists on the remote. ## The Terminal and the Identity Line The pane has a shell terminal at its foot, opened with **Show terminal**, already in the right working directory. It is there for the things a UI should not pretend to do: resolving a conflict, an interactive rebase, a `git bisect`. Beside it, Lanes shows the **git identity** that commits from this repository will carry. Seeing the wrong name there before you commit is the entire point. See [Git Identity](/docs/desktop/git-identity) to change it. ## What This Pane Does Not Do Worth stating plainly, so nothing surprises you mid-review: - **No staging area.** Staged and unstaged changes are one list, and committing includes everything. - **No hunk or line staging**, and no partial reverts within a file. - **No side-by-side diff.** Inline only. - **No blame, and no merge-conflict resolution.** Use the built-in terminal. ## Tips - Dock the pane while reviewing. The file list stays put while you work in the terminal next to it. - Use **History** to follow an agent commit by commit, and **Changes** to see where the working tree stands right now. - If a diff looks larger than expected, check the project's base branch. A wrong base makes an issue's diff include unrelated work. See [Settings](/docs/desktop/settings). - Committed something you did not mean to, and have not pushed? **Uncommit** on the History tab gives the changes back. Source: https://lanes.sh/docs/desktop/git-integration --- # Process Manager > Monitor every CLI process Lanes knows about: tracked sessions, orphans, and external agents. The process manager is a dialog that shows every CLI process Lanes knows about. It is your go-to tool for understanding what is running, cleaning up after crashes, and reclaiming system resources. ## Opening the Process Manager There are two ways to open it: - Click the **process icon** in the status bar at the bottom of the window. - Open it from the application menu. The dialog appears as an overlay on top of your board. ## What It Shows Each row in the process list displays: - **Process name** -- the CLI that is running (e.g., "claude", "codex"). - **PID** -- the operating system process ID. - **Associated issue** -- if the process is linked to an issue, its title appears here as a clickable link. You can jump straight to the issue from the process manager. - **Classification** -- one of three categories described below. ## Process Classifications Lanes organizes processes into three groups so you can quickly understand their origin and status: ### Tracked Processes that Lanes started and is actively managing. These are the normal case -- each one corresponds to a running session attached to an issue. Lanes monitors their lifecycle, captures their output, and tracks their metrics. ### Orphan Processes that Lanes previously started, but whose session has ended or disconnected. This can happen if Lanes exits unexpectedly, if a session crashes, or if a process outlives its session for any reason. Orphans are still running and consuming resources, but Lanes is no longer actively managing them. ### External CLI processes discovered on your system that Lanes did not start. Lanes scans for known CLI processes (like `claude` or `codex`) and lists them here for visibility. This helps you spot agents running outside of Lanes that might conflict with your work or consume resources. ## Killing a Single Process Click the **kill button** next to any process to terminate it. Lanes sends a graceful termination signal (SIGTERM) first. If the process does not exit within a few seconds, it escalates to a forced kill (SIGKILL). ## Stop Sessions vs Kill All At the top of the process manager, you have two bulk actions: - **Stop Sessions** -- Sends a graceful stop signal to all **tracked** sessions. This is the polite option: it tells each agent session to wrap up and exit cleanly. Orphan and external processes are not affected. - **Kill All** -- Terminates **every** process in the list, regardless of classification. Use this when you need a clean slate -- for example, after a crash left several orphaned processes behind. ## Refreshing the Process List Click the **refresh button** to rescan for current processes. The list updates to reflect any processes that have started or stopped since you opened the dialog. This is useful if you killed a process externally or if a session just finished. ## When to Use the Process Manager You do not need the process manager during normal operation -- Lanes handles session lifecycles automatically. It becomes useful in these situations: - **Orphaned processes consuming resources** -- After a crash or unexpected quit, orphaned agent processes may still be running. Open the process manager to find and kill them. - **Cleanup after crashes** -- If Lanes restarts after a crash, old processes may still be alive. The process manager shows them as orphans so you can clean up. - **Seeing what is running** -- When you have multiple sessions going and want a single view of all active processes, their PIDs, resource usage, and which issues they belong to. - **Spotting external agents** -- If something feels slow or a port is in use, check for external CLI processes that might be competing for resources. ## Tips - Get in the habit of checking the process manager after a crash or force-quit. Orphaned AI agents can rack up API costs if left running. - The status bar icon gives you a quick count of running processes without opening the full dialog. - Use "Stop Sessions" for routine cleanup; reserve "Kill All" for situations where you want everything shut down. Source: https://lanes.sh/docs/desktop/process-manager --- # Deep Links > Launch Lanes from Linear, GitHub, scripts, or bookmarklets with a pre-filled new-issue draft, or open it straight on a settings page. Lanes registers a custom URL scheme so other tools can put it somewhere useful. Click a `lanes://...` link in your browser, a Linear coding-tool button, a GitHub bot comment, or a shell script, and Lanes pops to the front with a new issue draft that already has the title, description, and upstream tracker fields filled in. It is the fastest way to capture an idea or kick off work without ever opening the app yourself. There are two actions: `new`, which is that draft, and `settings`, which opens a settings page. ## The URL Format A deep link looks like this: ``` lanes://new?prompt= [&title=] [&externalProvider=linear|github] [&externalKey=ABC-123] [&externalUrl=https%3A%2F%2F...] ``` Only `prompt` is required. Everything else is optional. The `new` action opens a draft issue in the backlog and surfaces it in the right-side panel, the same way Cmd+N does, except the fields are already populated. ## Parameters | Parameter | Required | What it does | |-----------|----------|-------------| | `prompt` | yes | The body of the issue. URL-encoded. If you omit `title`, the first non-empty line of `prompt` becomes the title (capped at 80 characters). | | `title` | no | Explicit issue title. Max 80 characters. Useful when the first line of your prompt is not a good headline. | | `externalProvider` | no | Upstream tracker the issue is linked to. Only `linear` or `github` are accepted; any other value is dropped. | | `externalKey` | no | The user-visible key of the upstream issue (e.g. `FOO-123` for Linear, `#456` for GitHub). Shown in the Lanes UI alongside the issue. | | `externalUrl` | no | Full URL to the upstream issue. Lanes uses this for the "open in Linear/GitHub" link on the issue. | **Template safety.** Any value containing `{{` or `}}` is dropped at parse time. This is so that if you wire Lanes into a tool that substitutes placeholders (like Linear's coding-tool template fields) and that substitution fails, you do not end up with literal `{{issue.identifier}}` text written into your issue. The rest of the link still works; only the broken value is discarded. ## Quick Examples Minimal, just a prompt: ``` lanes://new?prompt=Investigate%20the%20flaky%20auth%20test ``` A full Linear-style payload with every field set: ``` lanes://new?prompt=Add%20dark%20mode%20toggle%0A%0AIssue%3A%20FOO-123&title=Add%20dark%20mode%20toggle&externalProvider=linear&externalKey=FOO-123&externalUrl=https%3A%2F%2Flinear.app%2Ffoo ``` That second link decodes to: - **Title:** Add dark mode toggle - **Description:** Add dark mode toggle\\n\\nIssue: FOO-123 - **External link:** Linear issue FOO-123 at the given URL ## Quick Setup for Linear Linear has a dedicated integration point for tools like Lanes. Open [Linear's coding-tools settings](https://linear.app/lanes-sh/settings/account/preferences/coding-tools) and paste this into the **Custom link** field: ``` lanes://new?prompt={{prompt}} ``` That is the whole setup. Click the coding-tool button on any Linear issue and Lanes opens with a draft already populated. Linear does not yet expose separate variables for the issue title or identifier in the URL, so today everything Linear sends comes through as the prompt. That is fine: the identifier, branch name, and full Linear context all land inside the issue description, and the first line of that description becomes the title automatically. ## Use It From Anywhere Else Deep links are just URLs, so any tool that can open a URL can feed Lanes. **Terminal (macOS):** ```bash open 'lanes://new?prompt=Look%20into%20PR%20%23482' ``` **Browser bookmarklet skeleton:** ```javascript javascript:location.href='lanes://new?prompt='+encodeURIComponent('Capture this page: '+document.title+'\n\n'+location.href) ``` Drag that into your bookmarks bar and you have a one-click "send the current page to Lanes" button. **Shell alias for quick capture:** ```bash alias lcap='f(){ open "lanes://new?prompt=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$*")"; }; f' ``` Then `lcap "debug the rate limiter on /api/search"` opens Lanes with that prompt waiting. The only hard requirement is URL-encoding. Spaces become `%20`, newlines become `%0A`, ampersands become `%26`, and so on. Most languages have a one-liner for this (`encodeURIComponent` in JS, `urllib.parse.quote` in Python, `jq -sRr @uri` in shell). ## Opening Settings The second action takes one optional parameter: ``` lanes://settings[?page=] ``` `page` names a page in the settings sidebar: `integrations-link`, `integrations-github`, `agents-harness`, `appearance`, and so on. Leave it off and Settings opens wherever it was. ```bash open 'lanes://settings?page=integrations-link' ``` **A page id Lanes does not have is not a broken link.** Unlike an unknown action, which is dropped, an unrecognised `page` falls back to whatever page Settings was last on. That is deliberate: a link written against a newer or older version of Lanes still opens something real rather than appearing to do nothing. This is what [Lanes Link](/docs/desktop/lanes-link) uses. Running `lanes link desktop` in a terminal opens exactly the link above, which is a shorter path to that page than talking someone through the menu. It needs Lanes 0.48.0 or newer; an older build is still registered for `lanes://`, so the link appears to succeed and the app simply ignores it. ## Tips - **Title fallback.** If you skip `title`, Lanes uses the first non-empty line of `prompt`. Lead with a short headline, then a blank line, then the body, and you get a clean title-plus-description for free. - **Unknown params are ignored.** Lanes will gain new deep-link actions over time. Add custom query params today and they will be silently dropped, so links you build now stay forward-compatible. - **The action is `new` for now.** Today every deep link creates a new backlog issue. Future actions (focus an existing issue, open a session, jump to a view) will use the same scheme with different paths. - **Cold-start works.** If Lanes is not running when a deep link fires, macOS launches it and the link is delivered to the app once it boots. You do not need to keep Lanes open to use this. Source: https://lanes.sh/docs/desktop/deep-links --- # Git Identity > Switch between GitHub accounts and keep the git author name and email on your commits attributed to the right one. If you have ever committed to a work repository as your personal account, this is the button for you. **Git Identity** shows which GitHub account you are acting as and which name and email your commits will carry, and lets you change both. ![The Git Identity panel open from the Lanes app header, showing the active GitHub account, the current user.name and user.email for the repository, and actions to switch account, sign in, or clone a repository](/assets/whats-new-v045/git-identity.png) ## Opening the Panel Click the **account icon** in the app header. The tooltip tells you the state before you click: `GitHub -- `, `GitHub -- not signed in`, or `GitHub CLI not installed`. The panel is headed **Git Identity** and has three rows: - **`account`** -- the GitHub account currently active in the `gh` CLI. - **`user.name`** -- the git author name that commits will use. - **`user.email`** -- the git author email that commits will use. Any row that has no value reads **Not set**. The name and email are read for the folder you have selected, merging the repository's own config over your global one, which is exactly what git itself would do. With no folder selected you will see a `* No folder selected` note, and the values shown are your global ones. ## Switching Accounts Logged-in GitHub accounts are listed below the rows, with a checkmark on the active one. Click a different account to switch to it. Lanes then offers to update the commit identity to match, prefilled from that account's GitHub profile. This is the point of the feature: switching account without switching author identity is how commits end up attributed to the wrong person, so the prompt follows the switch rather than waiting for you to remember. The same prompt appears after signing in. Other actions in the panel: - **Sign in** and **Add another account** authenticate a new account. Lanes hands its own GitHub authorization to the `gh` CLI, so there is no second login flow to complete. - **Sign out** removes one specific account and leaves the others alone. - **Clone repository** lists your repositories and clones one straight into a Lanes project as the active account. ## Setting the Commit Identity The dialog is titled **Commit identity** or **Global git identity**, depending on what it is about to write, and takes a **Name** and an **Email**. Leave a field empty and that value is left untouched. The scope rule is the thing to understand: - **Repository-local** when the folder you have selected is a git repository. Lanes writes the repository's own config, so the change affects that project and nothing else. - **Global** when it is not. Lanes writes your global config, which is what repositories without their own identity fall back to. The dialog title tells you which of the two is about to happen, and a toast confirms it afterwards. ## Where the Identity Shows Up The active identity is rendered read-only at the foot of the [Changes](/docs/desktop/git-integration) pane, so you can see who is about to be credited while you are writing the commit message rather than finding out afterwards. The commit and pull-request dialogs read the same value. ## Requirements and Limits - **Account switching needs the [GitHub CLI](https://cli.github.com).** Lanes reads and switches accounts through `gh`. If it is missing, the panel tells you to install it with `brew install gh`. Setting the name and email works without it. - **Identities come from your GitHub accounts, or from what you type.** Lanes does not keep a separate list of named identity profiles. - **Scope is repository-local or global.** There is no per-worktree identity, so a worktree inherits the identity of the repository it belongs to. - **SSH keys are not managed.** Lanes changes who git says you are, not how you authenticate to a remote. Source: https://lanes.sh/docs/desktop/git-identity --- # Harness > The coding CLIs Lanes drives, with install state, version, and one-click updates for each one. A **harness** is the coding CLI that Lanes runs in its embedded terminal. Lanes reads session state from what the CLI writes to disk, so having one installed is the only setup needed. Two are supported today: **Claude Code** and **OpenAI Codex**. Open **Settings**, then **Agentic Coding**, then **Harness**. ![The Harness settings page in Lanes, showing a card per CLI with PATH status, version, install path, detected package manager, and Install or Update buttons](/assets/whats-new-v046/harness-settings.png) ## What the Page Shows One card per CLI, under **Available CLIs**: - **PATH status** -- `On PATH` or `Not on PATH`, resolved with `command -v`. - **Version** -- what the CLI itself reports when asked. - **Install path and manager** -- the resolved binary path, followed by whichever package manager put it there: npm global, bun global, pnpm global, yarn global, Homebrew, native installer, or unrecognised install. - **Update availability** -- an amber `· v2.2.0 available` when a newer version exists, or `· up to date` when there is nothing to do. **Recheck** re-runs the detection. The path is shown for diagnosis, not for editing. Lanes launches whatever is on your PATH, so if you want a different build, change what your shell resolves. ## Why the Update Verdict Is Sometimes Missing Lanes reads the latest published version from the package registry. If the registry cannot be reached, because you are offline or it is having a bad day, the card shows your installed version and offers no verdict at all. An unreachable registry means "no update to offer", never "you are behind" and never a bare claim that you are current. ## Installing and Updating **Install** appears only when the CLI is missing. **Update** appears only when it is present. Install prefers Homebrew when it is available, and falls back to npm. Homebrew goes first because it matches how Lanes itself installs, it is revertible, and it avoids piping a downloaded script into a shell. **Update uses the manager the CLI actually came from**, which matters more than it sounds. bun, pnpm, and yarn each keep global packages in their own prefix, so running `npm install -g` against a bun-installed CLI would report success while the old binary kept right on running. Lanes matches the manager it detected: | Detected manager | Update command | |---|---| | npm | `npm install -g @latest` | | bun | `bun add -g @latest` | | pnpm | `pnpm add -g @latest` | | yarn | `yarn global add @latest` | | Homebrew | `brew upgrade --cask ` | | native installer | `claude update` for Claude Code. Codex has no self-update, so it reinstalls. | Both actions run in a real terminal, so you watch the output rather than a spinner. ## Improving Codex Idle Detection The Codex card carries one extra block, **Improve idle detection (optional)**. Idle detection already works from Codex's rollout transcript. For a second signal, Codex can be told to write its status into the terminal title: ```toml [tui] terminal_title = ["status", "spinner", "project"] ``` **Add to Codex config** writes exactly that block to `~/.codex/config.toml` and leaves the rest of the file untouched. Once it is there, the card reads **Terminal title is configured**, and Lanes reads Codex's `Working` and `Ready` title states alongside the transcript. ## What Is Configured Elsewhere This page is deliberately about detection and lifecycle. Everything else about how a session runs lives where you launch it, or on a page of its own: - **Which harness a session uses** -- the **Harness** row in the session settings picker. See [Working with Sessions](/docs/desktop/sessions). - **Model** -- the **Model** row in the same picker. - **Flags** -- the **Flags** row in the same picker, plus per-repository flags and environment variables in [Settings](/docs/desktop/settings). - **Where requests go** -- [Gateway](/docs/desktop/gateway). - **Models on your own machine** -- [Local LLMs](/docs/desktop/local-llms). - **Terminal program** -- Settings, then Agentic Coding, then Terminal. - **Folder access** -- Settings, then User, then Permissions. Source: https://lanes.sh/docs/desktop/harness --- # Gateway > Point Lanes sessions at a model provider you host or buy elsewhere -- local Ollama or LM Studio, a vLLM box on your network, or a provider serving the same wire format. A **provider profile** points a CLI session at a model provider that is not the CLI's own provider. The harness stays the same; only where its requests go changes. That covers three shapes: - **Local** -- Ollama or LM Studio on your own machine, no marginal cost. - **Your own hardware** -- a vLLM box on the network. - **A provider** -- OpenRouter, z.ai GLM, or anything else serving the same wire format, billed per token. A profile is a session-scoped environment override, not a per-CLI feature -- it doesn't name a CLI at all. The session already chose one, so Lanes derives the right environment variable names from it at launch, which means one profile ("my Ollama box") serves whichever harness you point at it. Lanes applies it as real process environment when the session starts, so credentials never reach the terminal, the command line, or `ps`. Claude Code is the harness Lanes knows the variable names for today; other CLIs work through the profile's free-form environment table. ## Creating a profile Open **Settings**, then **Agentic Coding**, then **Gateway**. Pick a preset to prefill the base URL and auth mode, or add a blank profile. Adding a profile selects it, so there is nothing else to switch on: fill in the fields and it applies at the next session start. To switch between profiles later, or to go back to the CLI's own provider, use the **Gateway** row in the session settings picker. A profile with no base URL does nothing at all, so the half-filled one you are still typing into can't affect a session in the meantime. ![The Gateway settings page in Lanes, showing a list of provider profiles with base URL, auth mode, token, custom headers, and the Test connection button](/assets/whats-new-v046/gateway-settings.png) The presets are Ollama, LM Studio, vLLM, OpenRouter, and z.ai GLM. You still have to choose a model -- see below -- because Lanes cannot know which models your provider serves. Hit **Test connection** once you have a base URL. It sends a single one-token request and reports back. An "unknown model" result counts as success: the probe uses a placeholder model name on purpose, and the provider authenticated the request before rejecting it, which is exactly what the probe is checking. The **Claude Code advanced** section is collapsed by default and you can usually leave it alone: the two settings that matter most are already on. ## Bearer or API key The two auth modes differ only in which header carries the credential: `Authorization: Bearer` or `x-api-key`. **Prefer Bearer.** Most providers, OpenRouter and z.ai included, only read the Authorization header -- even though they call the credential an "API key", which is the trap in the name. API key mode has a second problem that **Test connection cannot catch**. Claude Code asks you to approve an API-key variable once per machine, and the prompt's default answer is *No*. Decline it and the key is ignored from then on without asking again, so the session falls back to your saved claude.ai login and sends *that* to your provider, which rejects it with a `401`. Meanwhile Test connection still passes, because Lanes sends the credential directly and never hits the approval gate. If you see a session `401` on a profile that tested fine: 1. Switch **Auth mode** to Bearer. There is no approval gate on that path, and it is what the provider most likely wants anyway. 2. Or re-enable the key in the session with `/config` → "Use custom API key". ## Why the token field is never empty Local providers ignore authentication entirely, so a credential looks unnecessary. It is not, and the local presets prefill the placeholder `local` for two reasons: 1. **A base URL on its own is not a credential.** With the token empty, the harness keeps using its own saved login as the active credential -- which means sending that credential to your provider. On a LAN box or a third-party provider that is a real disclosure. 2. **Sessions can stall at a login prompt.** With no saved login, a reachable base URL is not enough. The harness shows its login screen instead of starting, and a session Lanes spawned for you sits there waiting. Ollama and vLLM discard the value, so any string works. Lanes warns inline if you clear the field rather than blocking you. ## Choosing the model The profile deliberately has no model setting. Lanes already has one: the **Model** row in the session settings picker. Open it and type your provider's model id into the custom field. While a profile is active, that row stops offering the built-in model ids. That is deliberate -- an explicit first-party id is forwarded to your provider verbatim and rejected there. `Default` is not a safe pick either. With no model flag and no alias variables set, the harness sends its own default model id, which your provider won't serve any more than the others. **Name the model.** Two related things you may want, both through the profile's **Extra environment** table: - `ANTHROPIC_DEFAULT_HAIKU_MODEL` -- Claude Code uses Haiku for background work like conversation titling. Without this, those calls go out under a model name your provider doesn't serve and fail quietly. - `ANTHROPIC_MODEL` -- sets the model for every session on this profile, instead of picking it per session in the Model row. ## What you give up Routing a harness away from its own provider costs some features. For Claude Code specifically: - **The `thinking` / `adaptive` 400.** The most common failure. Claude Code treats any model name it does not recognise -- which is every model name your provider serves -- as one that accepts adaptive reasoning, and providers that do not accept it return a 400. The fix is **Disable adaptive thinking** under **Claude Code advanced**, on by default. Note this is *not* covered by the betas toggle: adaptive reasoning is selected by model, not by beta, so the two are separate levers. - **Pre-release request fields.** Context management and beta tool fields can be rejected with `Extra inputs are not permitted`. **Disable experimental betas** covers those, also on by default. - **Web search.** The Anthropic web-search server tool is unavailable. - **Prompt caching.** Entirely dependent on the provider. - **MCP tool search and fine-grained tool streaming.** Both off by default on a non-first-party host. MCP itself still works, including the Lanes MCP server. There is a toggle for tool search under **Claude Code advanced** if your provider passes `tool_reference` blocks through unchanged. - **Remote Control and voice dictation.** Both require a claude.ai identity and are unavailable while a profile is active. - **A startup warning.** Claude Code notes that two credential sources are active whenever you are also logged in to claude.ai. It uses the profile's credential. Run `/logout` if you want the warning gone. - **Context and output limits.** If your provider enforces a smaller window than the model's own, set **Auto-compact window** to its limit. The harness clamps that to at least 100,000 tokens, so a lower limit cannot be matched and `/compact` is the recovery. Set **Max output tokens** below the provider's output limit too. Anthropic does not support routing Claude Code to non-Claude models through a gateway. It works; it is not a supported configuration. ## Providers that speak a different wire format The profile targets providers serving the Anthropic Messages format (`POST /v1/messages`), which Ollama, LM Studio, vLLM, OpenRouter, and z.ai all do natively. For a backend that only speaks OpenAI's format, put a translating proxy in front of it and point the profile at the proxy. [LiteLLM](https://docs.litellm.ai/docs/anthropic_unified) exposes `/v1/messages` for any backend it supports: ``` uvx --from 'litellm[proxy]' litellm --config ~/litellm.yaml --port 4000 ``` Then use `http://localhost:4000` as the base URL. Lanes does not manage the proxy for you. ## Other CLIs Every profile has an **Extra environment** table, applied after everything else, so it wins over the mapped fields. That is how you drive a harness Lanes has no built-in mapping for: set its own base-URL and credential variables there directly. Because the profile doesn't name a CLI, the same one can carry Claude Code's variables and another harness's side by side. The **Gateway** row in the session picker shows for every CLI for that reason. Note that a CLI reading its provider config from a file rather than the environment -- Codex reads `~/.codex/config.toml` -- cannot be driven this way. **Test connection** always probes the Anthropic Messages format, since that is the one wire format Lanes knows. ## When a change takes effect At the **next session start**, matching how the terminal program and debug-mode settings behave. A running session keeps the environment it launched with. One consequence of sessions running through a login shell: anything your `.zshrc` or `.bashrc` exports overrides what the profile sets. If a profile looks like it is being ignored, check your shell profile for the same variable. ## Verifying from inside a session Run `/status` in a Claude Code session. An **`Anthropic base URL`** line confirms requests are routing to your provider, and an **`Auth token`** line confirms the profile's credential is active rather than your saved login. If neither line appears, the profile was not applied -- start a fresh session, since the change only lands at launch. Source: https://lanes.sh/docs/desktop/gateway --- # Local LLMs > Run coding agents against a model on your own machine. Lanes manages Ollama end to end, sizes models to your hardware, and sets a context window that survives a real agent prompt. Lanes can run a coding session against a model on this machine. Nothing leaves it: no tokens bought, no prompt sent anywhere. **Ollama** is the first provider Lanes manages directly, so installing it, starting it, pulling models, and wiring a session to it all happen from one page. Open **Settings**, then **Agentic Coding**, then **Local LLMs**. It is marked Research Preview: it works, and the rough edges are the ones we most want to hear about. ![The Local LLMs settings page in Lanes, showing the Ollama status card and a table of installed and recommended models with Details, Size, and Fit columns](/assets/whats-new-v046/local-llms-settings.png) ## Managing the Ollama Server The status card tells you what is true right now: `Serving on localhost:11434`, `Installed but not running`, or `Not installed`, with the version when there is one. An emerald **Sessions use this** chip appears when Ollama is the provider your sessions currently launch against. - **Install** installs Ollama with Homebrew. - **Start server** and **Stop server** use the right lever for how Ollama got here. A Homebrew formula is driven through `brew services`; the desktop app is opened and quit as an app. You do not have to remember which one you have. - **Recheck** re-runs the detection. - **Uninstall** is a two-step button that stops the service and removes Ollama. Models you pulled stay in `~/.ollama`, so reinstalling does not re-download them. Every one of these runs in a real terminal, so you see exactly what happened. ## Your Installed Models The **Installed** list shows what is on disk: size, parameter count, quantization, capabilities, and the context length the model was trained with. It works with the daemon stopped. When Ollama is not serving, Lanes reads the model manifests in `~/.ollama` and sums the layer sizes rather than showing you an empty page. Manifests carry no parameter count or quantization, so those columns read `--` until the daemon is up again. ## Finding a Model **Browse the library** holds two things. **Recommended** is a short curated list, with a checked download size and a minimum memory figure per model, compared against the RAM this machine actually has. It is the only list carrying a hardware claim, because ollama.com does not publish download sizes. Below it is the **full ollama.com library**, searchable, with capability and parameter chips but no sizing advice. If you already know what you want, **Pull any model by name** takes any reference, including `hf.co/user/repo:tag`. Both lists share one table: **Model**, **Details**, **Size**, and **Fit**. ### The Fit Column **Fit** is the answer to "will this work on this machine", before you spend the download: - **A context figure**, for example `128k context`, means it fits with room. - **`Fits at 64k, little headroom`** means it works, but you are close to the edge. - **`Needs ~38k, this machine fits ~24k`** means the model cannot hold a real session prompt here. See below for why that matters. - **`No tool support`** means the model cannot run an agent session at any context size. An agent that cannot call tools cannot edit files. Fit warns, it does not block. Pull it anyway if you know what you are doing. ## Why Context Is the Thing That Decides A real session sends roughly **38,000 tokens before you type anything**: the harness's own instructions, its built-in tool definitions, and the tool definitions of every MCP server you have connected. Ollama picks a context size from available memory, commonly far below that, and when a prompt overflows it **truncates instead of failing**. It keeps the head and the tail and drops the middle. What reaches the model is tool-definition JSON with the instructions cut out, so the model answers with something that looks like nonsense and nothing anywhere reports the loss. So Lanes sizes the context itself and bakes it into the model. - On pull, Lanes creates a variant tagged **`-lanes`** with `num_ctx` set, using your total memory and that model's own key/value geometry to pick the number. It reads the running daemon's cache setting too, since that halves or doubles the per-token cost. - The variant **shares its data with the base model**, so the `Size` column reads `shared` and it costs nothing extra on disk. It survives reboots and service restarts, and `ollama rm` undoes it. - The **re-bake** action on a row rebuilds the variant when the estimate changes, for instance after you close something large or change the daemon's cache setting. - The **Context window** field overrides the estimate. Leave it on `Auto` unless you have a reason. The subtext tells you what the loaded model is serving with right now. If a model does not fit, the lever that helps most is connecting fewer MCP servers. Their tool definitions are a large share of that 38,000. ## Using a Local Model in a Session Once Ollama is serving and has at least one model, Lanes registers it as a [Gateway](/docs/desktop/gateway) provider automatically, and sessions start using it. Three details worth knowing: - Registration **waits for the first model**. Activating an empty daemon would break every new session, so Lanes does not. - It **never overrides a provider you picked yourself**, and it matches on the base URL, so a profile you renamed or hand-made for `localhost:11434` is adopted rather than duplicated. - If you **delete the profile**, it stays deleted. Nothing re-adds it behind your back. The page shows a **Not in Gateway** row with an **Add to Gateway** button if you want it back. Ollama is the one provider Lanes can interrogate, so the session picker's **Model** row lists the tags your daemon really has. Switching to Ollama rewrites a leftover first-party model id to a real tag, and switching away clears the Ollama tag. `Default` is hidden while a local model is active, because it is the one guaranteed-wrong choice: it would send a model id your daemon does not serve. There is also a shortcut in the app header, next to the keep-awake button, that shows live daemon state and can start or stop the server without opening Settings. ## Why LM Studio Is Not Managed LM Studio serves the same wire format and works fine as a [Gateway](/docs/desktop/gateway) provider, as does a vLLM box on your network. What Lanes cannot do for them is the lifecycle: install, start, stop, and pull a model with a checked size. Ollama exposes all of that to a command line and a local HTTP API, which is why it is the one that gets a page. Point a profile at anything serving the Anthropic Messages format and it will work. You just do the driving. Source: https://lanes.sh/docs/desktop/local-llms --- # Collaboration > Invite your team, share a workspace, chat, and run AI agents together on the same board. Lanes is built for solo work and for teams. A shared remote workspace puts every member on the same live board: issues update in real time, agents run in parallel, and everyone sees the same state. Ship faster by running ten agents at once across your team instead of one. ## Workspaces There are two kinds of workspaces: | Type | What it is | |------|-----------| | **Local** | Private to your device. No sync, no sharing. Good for personal or offline work. | | **Remote** | Synced to the Lanes server. Sharable with your team. Supports all collaboration features. | To create a remote workspace, open **Settings > Workspaces** and click **New Workspace**. Give it a name and confirm. The workspace is ready to use immediately. To switch between workspaces, use the switcher in the top-left of the sidebar. Switching reloads the board with that workspace's issues, sessions, labels, and members. Admins can rename a workspace from **Settings > Workspaces**. Deleting a workspace is permanent and removes access for everyone. ## Inviting Members Open **Settings > Members** and click **Invite**. Enter the person's email address, choose a role, and send the invite. They will receive a link by email to accept and join. Pending invites appear in the members list with a **pending** badge. If the link expired or was missed, use the resend button to send a fresh one. There are two roles: | Role | What they can do | |------|-----------------| | **Admin** | Invite and remove members, change roles, rename and delete the workspace, and do everything an editor can | | **Editor** | Work on issues, start sessions, and use the workspace chat | You can change a member's role at any time from the members list. Removing a member revokes their access immediately. ## Chat Every remote workspace has a built-in chat panel. Open it from the right sidebar. Chat is scoped to the workspace, not to individual issues, so it is the natural place for quick coordination: "taking the auth bug", "deploying in 5", "anyone reviewing the PR?". Messages arrive in real time for all online members. When someone is composing a message, a typing indicator appears at the bottom of the chat. The chat icon shows an unread badge when there are new messages while you are elsewhere in the app. Opening the chat marks everything as read. ## Working Together on Issues All members share the same live board. When a teammate starts a session, moves an issue, or completes a task, your board reflects it within seconds without a refresh. Each issue card shows the assignee's avatar when someone has claimed it, so you can tell at a glance who is working on what. Presence updates periodically, so you also have a sense of who is currently active in the workspace. The real multiplier is parallel agents. Instead of one person queuing up tasks sequentially, your whole team can fan out: one engineer handles the bug, another builds the new endpoint, a third cleans up tests. Every one of those agents runs at the same time, on the same board, visible to everyone. This is where team vibe coding compounds: more agents running means more ground covered per hour, without anyone stepping on each other. ## Claiming Issues and Locks When you start a session on an issue, it becomes assigned to you. Your avatar appears on the card and stays there even after the session stops. Assignment is persistent: it tracks who last worked on the issue, not just who is active right now. While your session is active (starting, busy, or awaiting input), the issue is locked. Other members can see the issue is in use but cannot start their own session on it. The lock lifts automatically the moment you stop the session. There is nothing to configure. If a teammate's issue is locked and you need it, ask them to click Stop. The issue stays assigned to them, but the lock clears and you can take over. ## Taking Over an Issue You can take over a stopped issue that is assigned to someone else. Open the issue detail panel and you will see a **Take over terminal** button (or **Reassign and start a new session** for AI sessions). A confirmation prompt shows whose session you are taking over. Confirming reassigns the issue to you. If you are starting an AI session, the previous session's terminal history is cleared and a fresh session begins. If you are taking over a bare terminal, it reconnects under your ownership. You cannot take over an actively running session. If someone else's agent is still going, the detail panel shows: "This issue cannot be taken over until [name] clicks Stop." Wait for them to stop, or coordinate over chat. ## Governance Every workspace requires at least one admin at all times. You cannot demote or remove the last admin, and the UI will block you if you try. Only admins can: - Invite and remove members - Change member roles - Rename the workspace - Delete the workspace To hand off admin access to someone else, first promote them to admin, then you can safely demote yourself or leave the workspace. Deleting a workspace is immediate and permanent. All issues, sessions, labels, and member access are removed for everyone. There is no undo. Source: https://lanes.sh/docs/desktop/collaboration --- # Settings & Configuration > Configure appearance, project directories, workflow behavior, labels, quick commands, CLI flags, model providers, notifications, and permissions. Lanes is configurable out of the box. The Settings dialog gives you control over appearance, project directories, workflow behavior, labels, quick commands, CLI flags, environment variables, notifications, and permissions, all in one place. ## Opening Settings - Press **Cmd+,** (the standard macOS shortcut), or - Click the **gear icon** in the sidebar. Settings are organized into a sidebar down the left, in four groups: - **Workspace** -- Workspaces, Members, and Projects. - **User** -- General, Appearance, Notifications, Workflows, and Permissions. - **Agentic Coding** -- Terminal, [Harness](/docs/desktop/harness), [Local LLMs](/docs/desktop/local-llms), and [Gateway](/docs/desktop/gateway). Everything about how a session runs and where its requests go. - **Integrations** -- [Lanes MCP](/docs/desktop/local-mcp), [GitHub](/docs/desktop/github-integration), and [Linear](/docs/desktop/linear-integration). ## Per-Project Configuration (`.lanes/`) Quick commands, labels, CLI flags, and the base branch are **per-project**. They live in plain JSON files inside a `.lanes/` directory at the root of each repo: ``` .lanes/ quick-actions.json # Quick commands for this repo labels.json # Labels for this repo cli-flags.json # CLI flags + environment prefix variables general.json # Base branch and other project settings ``` These four are edited in the **Project Settings** dialog, which has a tab each for **General**, **Quick Actions**, **CLI Flags**, and **Labels**. Open it from **Settings > Workspace > Projects**, or right-click a project in the sidebar and choose **Project Settings**. Editing there writes to the same files. You can also hand-edit them, review changes in PRs, and version them like any other config. ### Commit it or gitignore it You get to choose how `.lanes/` is shared: - **Commit it (default).** Treat it like `.vscode/` or `.editorconfig`. Your team gets the same quick commands, labels, and CLI flags out of the box, and changes flow through normal code review. - **Gitignore it.** Add `.lanes/` to your `.gitignore` if you want personal, per-user config that does not bleed into the repo. Each developer keeps their own setup locally. Mix and match if you like: commit `.lanes/labels.json` for a shared taxonomy while gitignoring `.lanes/cli-flags.json` for personal model preferences. ### Migrating from earlier versions If you upgraded from a pre-v0.37 build, open **Settings > Project Settings** the first time. It walks you through the new layout, keeps your existing global settings as defaults, and lets you push them down into individual repos at your own pace. ## General **User > General.** Covers the basics: ### Working Folder Add or remove **project directories** that appear in the sidebar file browser. Click the add button to pick a folder from your file system. Each directory you add becomes a browsable project in the sidebar. ### Default Worktree Strategy Controls what happens when you start a new session from an issue: - **None** -- No worktree is created. The session runs in the project root. - **Create** -- Lanes automatically creates a new Git worktree for the issue. - **Select** -- Lanes prompts you to choose an existing worktree or create one. ### Default Start Mode Sets the initial mode for new agent sessions: - **Plan** -- The agent starts in planning mode, where it outlines an approach before making changes. - **Implement** -- The agent starts in implementation mode and begins working immediately. ### Auto-Update Toggle automatic updates on or off. When enabled, Lanes checks for new versions and installs them in the background. ## Appearance **User > Appearance** controls how Lanes looks across the app and the embedded terminal. ### Theme Choose between **Light**, **Dark**, or **System**. The System option follows your operating system preference and switches automatically. ### App Font Size Scales the size of text across the Lanes interface (sidebar, board, panels, dialogs). Bump it up if you want larger UI text, dial it down to fit more content on screen. ### Terminal Font The font family used inside the embedded session terminal. Pick any monospaced font installed on your system. This is independent of the app font, so you can run a tighter UI font alongside a roomier terminal font (or vice versa). ### Terminal Font Size Sets the font size for the session terminal only. Useful when you want larger code while keeping the surrounding UI compact. ### Danger Zone At the bottom of the Appearance page there is a **Reset Preferences** action. This wipes your local appearance settings back to defaults. It does not touch project directories, `.lanes/` config, or your issues. ## Notifications **User > Notifications** controls the sounds Lanes plays for workflow events. ### Done Sound When an issue moves into the Done column, Lanes plays a sound. The default is a gong. If that does not fit your environment, the page ships a small library of alternatives: Chime, Success, Tada, Fanfare, Victory, Level Up, Happy Bells, Long Pop, and Sci-fi. You can also upload your own audio file. Supported formats: `.wav`, `.mp3`, `.ogg`, `.m4a`, `.aac`, `.flac`. ### Volume and Preview A volume slider sets how loud the sound plays, and a preview button lets you hear the current selection before unleashing it on your standup. Tune both before committing to a custom sound. You can reach the same controls from **Configure > Notifications > Sound**. ## Workflows **User > Workflows** displays the current workflow steps that your issues move through: **Planning** -- **Implementation** -- **Review** -- **Done** The step sequence defines the columns on your board and the lifecycle stages for every issue. ## Labels **Project Settings > Labels.** Manage the labels you use to categorize issues: - **Create** a new label by clicking the add button, giving it a name, and picking a color. - **Edit** an existing label by clicking on it to change the name or color. - **Delete** a label you no longer need. Lanes offers **13 colors** to choose from, giving you enough variety to create a meaningful color-coding system. You can also manage labels from the board itself, right-click any issue and use the **Add Label** option in the context menu. This page is the place for bulk management: renaming, recoloring, and cleaning up old labels. Labels are per-repository and stored in `.lanes/labels.json`. Renames cascade atomically across every issue in that project. ## Quick Actions **Project Settings > Quick Actions** is where you create, edit, and organize your [quick commands](/docs/desktop/quick-commands): - **Create** a new command by clicking the add button. - **Set its properties**: name, prompt or shell command, type (Claude or Terminal), and category (General or Worktree). - **Edit** an existing command by clicking on it. - **Reorder** commands by dragging them up or down. The order here determines the menu order and which keyboard shortcut (Cmd+Alt+1-9) maps to each command. - **Delete** commands you no longer use. Quick commands are per-repository and stored in `.lanes/quick-actions.json`. ## CLI Flags **Project Settings > CLI Flags** has two sections: environment prefix variables and CLI flags. Together they control the full command that Lanes builds for every session. These are per-repository and stored in `.lanes/cli-flags.json`, so a monorepo that needs `--dangerously-skip-permissions` can have it without forcing the same on a side project. ### Environment Prefix Variables Set environment variables that are injected before the CLI command. When a session starts, Lanes prepends these variables so the resulting command looks like: ``` CLAUDE_CODE_NO_FLICKER=1 claude --flag1 --flag2 ``` Each entry is a `KEY=VALUE` pair. Lanes includes one default entry: - **`CLAUDE_CODE_NO_FLICKER=1`** -- Enables Claude Code's [fullscreen rendering mode](https://code.claude.com/docs/en/fullscreen). This is pre-filled and enabled by default. Toggle it off if you prefer the standard rendering behavior. Fullscreen rendering eliminates terminal flicker, keeps memory usage flat in long conversations, and adds mouse support (click to expand tool output, click URLs, drag to select text). It draws the interface on the terminal's alternate screen buffer, similar to `vim` or `htop`, and only renders messages that are currently visible. The difference is most noticeable in the VS Code integrated terminal, tmux, and iTerm2. You can add your own entries with the add button. A few useful Claude Code environment variables: - `CLAUDE_CODE_DISABLE_MOUSE=1` -- Keeps the flicker-free rendering but disables mouse capture, so your terminal's native text selection works normally. Useful over SSH or inside tmux. - `CLAUDE_CODE_SCROLL_SPEED=3` -- Multiplies the mouse wheel scroll distance (1-20). Helpful when your terminal sends one scroll event per notch. See the full list in the [Claude Code fullscreen docs](https://code.claude.com/docs/en/fullscreen). Remove or toggle entries as needed. Environment prefix variables apply to every session **in this repository**, just like CLI flags. They do not leak across repos. Note that these are typed into the terminal as a command prefix, so the values land in scrollback and in `ps`, and `.lanes/cli-flags.json` lives inside your repo. Do not put credentials here. To route a session at your own model provider, use the [Gateway](/docs/desktop/gateway) page instead: it stores the credential outside the repo and injects it as real process environment. ### CLI Flags Add **persistent flags** that are appended after the CLI command. Each flag is a key-value pair in the format `flag=value`. For example, if you want every Claude Code session in this repo to run with permissions bypassed, you could add: ``` --dangerously-skip-permissions ``` Flags set here apply to every session in the current repository. Use this for settings you always want active in that project, rather than typing them each time. Lanes captures the active CLI flag set at session launch, so the side panel keeps showing what an issue actually ran with even after you change your selections later. ## Agentic Coding Four pages govern how a session runs. Each of the last three has a reference page of its own: - **Terminal** sets which terminal Lanes **identifies as** to the CLIs running inside its embedded terminal, by way of the reported `TERM_PROGRAM`. It does not open an external terminal. The default is iTerm, because iTerm, Ghostty, and WezTerm use a silent bell while Apple Terminal answers a bell by pulling the real Terminal.app to the foreground. Applies to sessions started after you change it. - **[Harness](/docs/desktop/harness)** shows the coding CLIs Lanes drives, whether each one is installed and current, and installs or updates them. - **[Local LLMs](/docs/desktop/local-llms)** manages Ollama and the models on this machine. - **[Gateway](/docs/desktop/gateway)** points sessions at a model provider you host or buy instead of the CLI's own. ## Permissions **User > Permissions** helps you verify that Lanes has the system access it needs: ### Folder Access Verification Confirms that Lanes can read and write to your configured project directories. If a folder is inaccessible (moved, deleted, or permission-restricted), this tab flags the issue. ## Per-Project Base Branch Each project has a **base branch** used for Git diff comparisons. Lanes auto-detects this from your repository (typically `main` or `master`), but you can override it manually if your project uses a different convention (e.g., `develop` or `trunk`). The base branch setting affects: - What the Git Changes view compares against. - How worktree branches are diffed. You can set this from the project settings or it will be picked up automatically the first time you add a project directory. The value is stored in `.lanes/general.json`. As of v0.37, base-branch detection prefers your local refs over `origin`, so a local `main` that is ahead of remote no longer distorts your worktree sync count. ## Tips - Set your theme to System so Lanes follows your OS dark/light preference automatically. - Add all your active project directories upfront, switching between projects is faster when they are already in the sidebar. - Commit `.lanes/` for shared team setup, or add it to `.gitignore` if you want per-developer personal config. - Use the CLI Flags tab sparingly. Both flags and environment variables apply to every session in the repo, so only add ones you truly want there. - Leave `CLAUDE_CODE_NO_FLICKER=1` enabled unless you have a specific reason to disable it. It smooths out terminal rendering for most setups. - Review the Permissions tab if sessions fail to start or files are unreadable. Source: https://lanes.sh/docs/desktop/settings --- # Keyboard Shortcuts > Every keyboard shortcut available in Lanes, grouped by context. Lanes is designed to be fast with a mouse, but even faster with the keyboard. This page lists every keyboard shortcut available, grouped by context. ## Global Shortcuts These work from anywhere in the app. | Shortcut | Action | |----------|--------| | **Cmd+N** | Create a new CLI session. From inside an existing session, this spawns a sibling on the same issue. | | **Cmd+T** | Create a new bare terminal | | **Cmd+,** | Open Settings | | **Cmd+D** | Complete the selected issue(s) | | **Cmd+R** | Send the selected issue(s) in Planning or Implementation to Review | | **Cmd+E** | Remove the selected issue(s) (with confirm). Backspace stays available inside inputs. | | **Cmd+A** | Select all issues in the current column; press again to select all issues on the board | | **Escape** | Clear the current selection, or close the open dialog | ## Board Shortcuts These apply when interacting with the board. | Shortcut | Action | |----------|--------| | **Shift+Click** | Range-select issues (selects every card between the last selected and the clicked one) | | **Cmd/Ctrl+Click** | Toggle selection on an individual issue without affecting others | | **Drag card** | Move an issue between columns by dragging it | | **Right-click card** | Open the context menu with options like Move, Complete, Stop Runtime, Remove, Add Label, Add Dependency, and Move to Backlog | ## Quick Commands Run your configured quick commands by position. | Shortcut | Action | |----------|--------| | **Cmd+Alt+1** | Run the 1st quick command | | **Cmd+Alt+2** | Run the 2nd quick command | | **Cmd+Alt+3** | Run the 3rd quick command | | **Cmd+Alt+4** | Run the 4th quick command | | **Cmd+Alt+5** | Run the 5th quick command | | **Cmd+Alt+6** | Run the 6th quick command | | **Cmd+Alt+7** | Run the 7th quick command | | **Cmd+Alt+8** | Run the 8th quick command | | **Cmd+Alt+9** | Run the 9th quick command | The numbering follows the order in **Settings > Quick Actions**. Reorder your commands there to put your most-used ones on the lowest numbers. ## Terminal & Editor These shortcuts work inside the terminal and file editor areas. | Shortcut | Action | |----------|--------| | **Cmd+S** | Save the current file in the editor | | **Cmd+Enter** | Confirm the active dialog (equivalent to clicking the primary button) | | **Drag file onto terminal** | Inject the file's path into the terminal input | ## Tips for Efficiency - **Cmd+N is your starting point.** Creating a new CLI session is the most common action in Lanes. Memorize it first. - **Use Cmd+Alt shortcuts for repetitive tasks.** If you commit code ten times a day, make sure "Commit" is your first quick command so Cmd+Alt+1 fires it instantly. - **Cmd+A then Cmd+D is a power combo.** Select all issues in the Done column, then mark them complete to clear the board in two keystrokes. - **Cmd+R is your handoff to review.** From Planning or Implementation, press Cmd+R to push work to the Review column without opening the side panel. - **Escape is your universal "back" button.** Close dialogs, clear selections, and dismiss menus -- all with one key. - **Right-click is underrated.** The context menu on issue cards gives you access to nearly every action (move, complete, stop, label, dependency) without opening the issue detail panel. - **Drag files into the terminal** instead of typing paths. When you need an agent to look at a specific file, drag it from the sidebar file tree straight onto the terminal. Source: https://lanes.sh/docs/desktop/keyboard-shortcuts --- # Feedback & Community > Join our Discord, report bugs, and help shape what Lanes becomes. ## Discord Our [Discord server](https://discord.gg/B3f8QjqeBa) is the best place to ask questions, share feedback, and connect with other users. The team is active there daily. ## Reporting Bugs Found something broken? Post it in the **#bugs** channel on Discord with: - What you expected to happen - What actually happened - Your macOS version and Lanes version (visible in Settings) ## Feature Requests Have an idea? Drop it in **#feature-requests** on Discord. We read every suggestion and use them to prioritize the roadmap. ## Stay in the Loop We share updates, release notes, and previews in Discord before they go anywhere else. It is the fastest way to stay current on what is shipping. Source: https://lanes.sh/docs/desktop/community --- # Lanes Desktop MCP > Run Lanes Desktop MCP, a server on your machine, so Claude Code, Codex, and other agents can read your board and drive sessions. Lanes ships Lanes Desktop MCP, a server built into the app, so agents like Claude Code and Codex can connect directly to your workspace. Once enabled, an agent can read your issues, create or update them, and start sessions on your behalf, all from a chat prompt. It is currently available as a research preview on desktop. ![Claude Code calling Lanes MCP tools to list and triage issues from a chat session](/assets/mcp-support/mcp-preview.png) ## Enabling Lanes Desktop MCP Open Lanes, go to **Settings**, then **Local MCP**, and toggle it on. The server starts on port `5353` and exposes an SSE endpoint at `http://localhost:5353/sse`. Everything runs locally on your machine. ![Lanes settings page showing the Local MCP toggle and one-click Claude Code and Codex connect buttons](/assets/mcp-support/mcp-settings.png) ## Connecting an Agent From the same Settings panel, one-click buttons connect supported agents: - **Claude Code**: writes the `lanes-desktop` entry into `~/.claude.json`. - **Codex**: writes the `lanes-desktop` entry into your Codex config. After clicking the button, restart your agent. Lanes will show up as an MCP server named `lanes-desktop` alongside any others you already have configured. There is no JSON to edit by hand. If you use a different agent that supports MCP, point it at `http://localhost:5353/sse` manually using whatever connection format your agent expects. Prefer the terminal? With the server enabled, Claude Code can add the same entry in one command: ```bash claude mcp add --transport sse lanes-desktop http://localhost:5353/sse ``` ## Upgrading From an Older Name The local server used to register as `lanes-local`, and before that as plain `lanes`. **If you connected an agent under either name, do nothing.** Both keep working, and Lanes will not rename anything in your config unless you ask it to. Renaming is opt-in because an MCP server's config name is also the prefix on its tool names. Changing it moves every tool from `mcp__lanes-local__*` to `mcp__lanes-desktop__*`, which would reset your approvals and break anything that refers to the server by its old name. When you do want the new name, open **Settings**, then **Local MCP**. A notice appears for each agent still on an older name, with a button that renames the entry for you. For `lanes-local` that also rewrites your `mcp__lanes-local__*` permissions in `~/.claude.json` and `~/.claude/settings.json`, so tools you had already approved stay approved. Restart the agent afterwards to pick up the new name. Removing and re-adding the connection by hand works too, and gets the new name automatically. Plain `lanes` is a special case, because the hosted [Lanes MCP](/docs/mcp) uses that name. Renaming it frees `lanes` for the remote, and connecting the remote server performs the same rename since it needs the name back. Permissions are **not** carried over in this case: an `mcp__lanes__*` grant is ambiguous between the two servers, so Lanes leaves it alone and you re-approve when prompted. One thing Lanes cannot reach either way: permissions granted in a project's own `.claude/settings.local.json`. Those live in your repos rather than your home directory. Update any `mcp__lanes-local__*` entries there yourself, or just re-approve the tools when prompted. ## What the Server Exposes The server provides the full surface you would expect from a workspace agent. ### Issues List, search, create, update, move, and delete issues. You can filter by column, label, component, or free text. Helper tools let the agent resolve labels and components by name to the right UUIDs before tagging, so the agent does not need to hard-code IDs. ### Sessions Start a Claude Code, Codex, or shell session against any issue. Sessions can be launched in plan mode, started with a custom prompt, given extra CLI flags or environment variables, and optionally tied to a freshly-created git worktree so they run in isolation. Sessions can also be stopped, and the agent can check status across every running session at once. ### History and progress Pull the message history of a past or current session, fetch session statistics, and read the live terminal scrollback. This lets the agent answer "how is this issue going" without you describing the state by hand. ### External issue trackers Once you connect GitHub or Linear inside Lanes settings, the same MCP server exposes their issues to the agent too. Read, search, create, and comment on GitHub and Linear issues without leaving the chat — and pair those calls with `lanes_create_issue` to import a ticket, work on it locally, and post the result back upstream. Tokens stay on disk in `integrations.json`; the agent never sees them. See [GitHub Integration](/docs/desktop/github-integration) and [Linear Integration](/docs/desktop/linear-integration) for the connect flow. ## Server Details For MCP registries, indexers, and clients connecting manually: - **Server name:** `lanes-desktop` (older installs registered as `lanes-local` or `lanes`; both still work, see [Upgrading From an Older Name](#upgrading-from-an-older-name)) - **Transport:** SSE (Server-Sent Events) - **Endpoint:** `http://localhost:5353/sse` - **Protocol version:** `2024-11-05` - **Server version:** `1.0.0` - **Tool count:** 30 (18 workspace + 6 GitHub + 6 Linear) - **Install:** Open Lanes → **Settings** → **Local MCP** → toggle on. Use **Connect Claude Code** or **Connect Codex** for one-click config injection, or point any MCP-capable client at the endpoint above. - **Source:** Built into the Lanes desktop app — no separate package to install. ## Tool Reference The complete list of `lanes_*` tools, their parameters, and live descriptions tracks the server's `tools/list` response. Rather than duplicate them here, the canonical sources are: - **Source and full reference:** [github.com/lanes-sh/app](https://github.com/lanes-sh/app) documents every workspace, GitHub, and Linear tool, kept in sync with each release. - **Live discovery:** any MCP-capable client can call `tools/list` against `http://localhost:5353/sse` to enumerate the current surface with full JSON schemas. ### Claude Code plugin For Claude Code users, the bundled `lanes-desktop` plugin ships three skills that wrap these tools with workflow guidance, plus a setup command, so you rarely call them by hand: - `lanes-sessions`: end-to-end usage, UUID gotchas, the multi-session model, common workflows, anti-patterns. - `github-lanes-bridge`: import GitHub issues, run them locally, post results back. - `linear-lanes-bridge`: the same loop for Linear, including sprint imports and issue decomposition. - `/lanes:setup-mcp`: slash command for one-shot setup of the Lanes Desktop MCP connection. Install the `lanes-desktop` plugin from the Claude Code marketplace, then just mention Lanes in chat and the right skill loads automatically. ## Example Prompts A few prompts that work today: - *"What's on my plate in Lanes?"* List and triage issues from chat. - *"Create an issue in Lanes for the login bug, tag it with frontend."* Capture work without leaving your agent. - *"Start a Lanes session on issue 12 in plan mode."* Launch an isolated worktree session with the right flags. - *"How's issue 8 going?"* Pull session history, terminal output, and progress without switching windows. - *"Move every done issue from this week into archive."* Bulk board hygiene in one line. - *"Import GitHub issue lanes-sh/core#42 into Lanes and start a session on it in a fresh worktree."* Cross-system pipe in one prompt. - *"Find the Linear issue ENG-321, work on it locally, and post a comment back on it linking to the PR when you're done."* Round-trip a Linear ticket without leaving chat. The most interesting workflows are chains: an agent that reads your backlog, picks the next ticket based on labels, starts a session in a worktree, then reports back when it gets blocked. With GitHub and Linear connected, those chains can also start (and finish) on the team's source of truth, not just the local board. ## Research Preview Lanes Desktop MCP is shipping as a research preview. The tool surface is stable enough to build on, but expect it to evolve as we learn how people use it. If something feels missing or wrong, that is the kind of feedback we want right now. Share workflows or ideas in our [Discord](https://discord.gg/B3f8QjqeBa). Source: https://lanes.sh/docs/desktop/local-mcp --- # GitHub Integration > Connect Lanes to GitHub to import issues, push results back, and reach GitHub from your agent via the Lanes MCP. Lanes connects to GitHub so you can pull issues into the local board, work on them with an agent in a worktree, and post the result back to GitHub when you're done. The same connection also gives the [Lanes Desktop MCP](/docs/desktop/local-mcp) server a GitHub tool surface, so Claude Code or Codex can do all of this from a chat prompt without you copying URLs around. ## Connecting GitHub Open Lanes, go to **Settings**, then **GitHub**, and click **Connect**. Lanes opens your browser to the GitHub OAuth screen; once you approve, the browser redirects back to a local loopback URL and the settings page flips to **Connected as <your handle>**. Behind the scenes: - The OAuth flow is brokered by the Lanes API (it holds the `client_secret`). - Scopes requested: `repo` and `read:user`. The first lets Lanes list and create issues; the second is the username smoke-check after connect. - The access token is stored locally in `integrations.json` inside the Lanes data directory and never leaves your machine. - GitHub access tokens do not expire, so there is no refresh flow to worry about. A revoked token surfaces as a `401` on the next call. To disconnect, click **Disconnect** on the same settings page. Lanes wipes the token from `integrations.json`; you can reconnect at any time. ## Reconnecting After a Revoked Token If your GitHub token is revoked (for example, you rotated it from GitHub's settings), the next API call will fail with a `401`. Since v0.41, Lanes catches that case and surfaces a one-click **Reconnect** banner in the integration's settings page and on any UI that depends on the connection (the import dialog, the issue's external link badge). Click it to re-run the OAuth flow without losing your local data. Earlier builds failed silently when this happened; if you saw mysterious "could not fetch" errors on imported issues, this is the fix. ## Importing an Issue From any project view in Lanes, click **Import from GitHub** in the header. The import dialog shows: 1. A **repo picker** of every repo you can access (owner, collaborator, or organization member), sorted by most-recently-pushed. Type to filter. 2. The first 50 open issues in the selected repo, newest first. Pull requests are filtered out automatically. 3. A **search box** that hits GitHub's search API. When you type a pure number (`#9` or `9`), the exact-match issue is pinned to the top of the results even if it would otherwise fall off the list. Click an issue to import it. Lanes creates a draft on your board with the issue's title and body copied over, plus a marker line in the description linking back to the original GitHub URL. The new Lanes issue carries the `external*` metadata that powers the **external link badge** in the issue detail panel. ## Refreshing an Imported Issue Each imported issue shows an **external link badge** in its detail panel. The badge has an **open** button (jumps to the GitHub issue) and a **refresh** button. Refresh re-fetches the issue from GitHub and overwrites the local title and description with the upstream version — useful if the ticket got edited after import. If you made local edits since the last refresh, Lanes warns before overwriting them. You can keep the local copy or accept the upstream version. ## From the Agent (via MCP) Once GitHub is connected in settings, every `lanes_github_*` tool on the [Lanes Desktop MCP](/docs/desktop/local-mcp#github) server starts working. The agent can: - **List or search issues** in any of your repos. - **Read a specific issue** by number. - **Open new issues** — useful for spinning off a Lanes-only sub-task into a tracked upstream ticket. - **Post comments** on issues or pull requests — GitHub uses the same endpoint for both, so a single tool covers both cases. Example prompts: - *"Import GitHub issue lanes-sh/core#42 into Lanes and start a session on it in a fresh worktree."* - *"Comment back on lanes-sh/core#42 with the PR URL once the session lands."* - *"Open a new GitHub issue on lanes-sh/core titled 'follow-up: rate limiting' with these details from the session."* The agent reads the OAuth token from `integrations.json` directly — you do not paste it anywhere. The skill at `plugins/lanes-desktop/skills/github-lanes-bridge/SKILL.md` (ships with the `lanes-desktop` Claude Code plugin) walks an agent through the full import → execute → push loop and handles dedup, marker lines, and field mapping for you. ## Scope Limits A few things to know about what Lanes can and can't do via GitHub: - **Pull requests are not exposed via MCP today.** PR creation in Lanes happens through the local `gh` CLI from inside a session. Commenting on a PR works through `lanes_github_comment_on_issue` (same endpoint). - **Labels are by name, not ID.** When the agent creates an issue, it passes label names (`"bug"`, `"area:auth"`) — GitHub resolves them server-side. - **Rate limits apply.** `lanes_github_search_issues` hits the GitHub search API, which is rate-limited to 30 requests per minute on the authenticated tier. If you batch-import a huge backlog, expect the agent to pace itself. For the agent-side details and the full tool reference, see [Lanes Desktop MCP](/docs/desktop/local-mcp). Source: https://lanes.sh/docs/desktop/github-integration --- # Linear Integration > Connect Lanes to Linear to import tickets, comment results back, and reach Linear from your agent via the Lanes MCP. Lanes connects to Linear so you can pull tickets into the local board, work on them with an agent, and post the result back to Linear when you're done. The same connection lights up the Linear tool surface on the [Lanes Desktop MCP](/docs/desktop/local-mcp) server, so Claude Code or Codex can find a ticket, run a session on it, and comment back — all from chat. ## Connecting Linear Open Lanes, go to **Settings**, then **Linear**, and click **Connect**. Lanes opens your browser to the Linear OAuth screen; once you approve, the browser redirects back to a local loopback URL and the settings page flips to **Connected as <your name>**. ![The Linear settings page in Lanes showing the Connected state, the default team picker, and the how-it-works summary](/assets/integrations/integrations-settings.png) Behind the scenes: - The OAuth flow is brokered by the Lanes API (it holds the `client_secret`). - Scope requested: `read`. The grant also lets Lanes create issues and post comments through the same OAuth app, so the agent can push results back. - The loopback redirect uses a fixed port (`127.0.0.1:53682`). Linear requires an exact `redirect_uri` match, so the OAuth app is registered with that exact value and Lanes binds the same port. If something else on your machine is already using port `53682`, the connect flow will fail — close the other tool and try again. - The access token, refresh token, and expiry are stored locally in `integrations.json` and never leave your machine. To disconnect, click **Disconnect**. Lanes wipes the tokens from `integrations.json`; you can reconnect at any time. ## Token Refresh Linear access tokens are short-lived (about ten days). Refresh tokens are long-lived. Lanes refreshes proactively before every Linear call: - If the access token expires more than 60 seconds from now, use it as-is. - Otherwise call the Lanes API refresh endpoint with the refresh token, save the new pair to `integrations.json`, and use the new access token. The refresh path is serialised — concurrent Linear calls won't fire duplicate refresh requests that would invalidate each other. You don't need to do anything; the only time you'd notice the refresh logic is if the refresh token itself gets revoked. ## Reconnecting After a Revoked Token If the refresh token gets revoked (Linear rotates them when you revoke an OAuth app, sign out of all sessions, or change your password), the next Linear call fails with a `linear not connected` error. Since v0.41, Lanes catches that case and surfaces a one-click **Reconnect** banner in the integration's settings page and on any UI that depends on the connection (the import dialog, the issue's external link badge). Click it to re-run the OAuth flow without losing your local data. Earlier builds failed silently on this path; if you saw mysterious "could not fetch" errors on imported issues after a long break, this is the fix. ## Default Team The Linear settings page lets you pin a **Default team**. When set, the import dialog surfaces that team at the top of the team picker so you don't have to scroll past every team in your workspace. This is purely a UI convenience — the agent-facing MCP tools don't honour the default and always make the agent pick a team explicitly. ## Importing an Issue From any project view in Lanes, click **Import from Linear** in the header. The import dialog shows: 1. A **team picker**. If you set a default team, it's first. Otherwise teams are listed in Linear's default order. 2. The first 50 open issues in the selected team, sorted by most-recently updated. Completed and canceled issues are filtered out. 3. A **search box** that hits the Linear GraphQL API. Type free text to match title or description, or an issue key (`ENG-9`, `#9`, or `9`) to jump straight to a specific ticket. ![The Import from Linear dialog open over the Lanes board, with a team picker, search box, and list of upstream issues. Issues already imported are greyed out and show their local Lanes issue number.](/assets/integrations/integrations-add.png) Issues you've already pulled in are greyed out in the list with their local Lanes issue number shown alongside, so you can see at a glance what is and isn't on your board yet. Click an issue to import it. Lanes creates a draft on your board with the title and description copied over, plus a marker line linking back to the Linear URL. The new Lanes issue carries the `external*` metadata that powers the **external link badge** in the issue detail panel. ![A Lanes board showing an imported issue with an LA-45 badge linking back to the original Linear ticket](/assets/integrations/integrations-issues-list.png) ## Refreshing an Imported Issue Each imported issue shows an **external link badge** in its detail panel. The badge has an **open** button (jumps to the Linear ticket) and a **refresh** button. Refresh re-fetches the issue from Linear and overwrites the local title and description with the upstream version. If you made local edits since the last refresh, Lanes warns before overwriting them. You can keep the local copy or accept the upstream version. ## From the Agent (via MCP) Once Linear is connected in settings, every `lanes_linear_*` tool on the [Lanes Desktop MCP](/docs/desktop/local-mcp) server starts working. The agent can: - **List teams** you can access. - **List or search issues** in a team. The search tool understands `ENG-9` / `#9` / `9` shorthand for issue numbers. - **Read a specific issue** by its Linear UUID. (Note: the UUID is what the list/search tools return — not the human-readable identifier like `ENG-9`.) - **Open new issues** in a team. - **Post comments** on an issue. Example prompts: - *"Find Linear ENG-321 in the Eng team and pull it into Lanes."* - *"Comment on Linear ENG-321 with the PR link once the session lands."* - *"Open a new Linear issue in the Eng team called 'follow-up: cleanup' with these notes from the session."* The agent reads tokens from `integrations.json` directly, including the refresh path — you don't paste the access token anywhere. The skill at `plugins/lanes-desktop/skills/linear-lanes-bridge/SKILL.md` (ships with the `lanes-desktop` Claude Code plugin) walks an agent through the full import → execute → push loop, including dedup, sprint imports, and issue decomposition. ## Scope Limits A few things to know about what Lanes can and can't do via Linear: - **`lanes_linear_get_issue` takes the UUID, not the identifier.** Agents must look up the UUID via `list_issues` or `search_issues` first; passing `ENG-9` directly will fail. The skill above handles this for you. - **The default team is a UI affordance only.** Agents always see every team and must pick one explicitly. - **Linear projects, cycles, and priority are not currently mapped.** The import dialog and the MCP tools focus on the core issue fields (title, description, state, URL). For richer metadata, comment back on the Linear ticket rather than trying to mirror everything into Lanes. - **State mapping is not automatic.** Linear's state names are team-scoped; Lanes columns are `backlog` / `todo` / `in-progress` / `done`. On import, the agent (or you) decide where the ticket lands. For the agent-side details and the full tool reference, see [Lanes Desktop MCP → Linear](/docs/desktop/local-mcp#linear). Source: https://lanes.sh/docs/desktop/linear-integration --- # Building Loops > Drive Lanes sessions in an agent loop over the Lanes Desktop MCP: poll session status, verify with the diff, advance the board, and stop safely. An agent loop is the pattern behind "loop engineering": instead of prompting a session by hand, you design a system that starts work, checks it, advances it, and repeats until a goal is met or a human is needed. The Lanes [Lanes Desktop MCP server](/docs/desktop/local-mcp) gives you every piece a loop acts on. This page is the practical reference. For the background and the industry context, see the blog post [Loop Engineering: Stop Prompting, Start Looping](/blog/loop-engineering-with-lanes). ## Prerequisites - **Lanes Desktop MCP enabled.** Turn it on in Lanes under Settings, Local MCP, and connect your agent. See [Lanes Desktop MCP](/docs/desktop/local-mcp). - **The `lanes-desktop` skills** (optional but recommended). The `lanes-sessions` skill teaches the tool surface and the multi-session model, so you rarely call tools by hand. - **Lanes running.** The server is served by the desktop app on `http://localhost:5353/sse`. ## The loop, in one rule Every loop hangs off one signal: the session's runtime status, returned by `lanes_get_session_status`. | Status | Meaning | | --- | --- | | `none` | Issue exists, no session has run | | `starting` | Spawned, no output yet | | `busy` | Working, producing output | | `awaiting_input` | Paused for a human (prompt, picker, permission dialog) | | `stopped` | Terminal closed cleanly | | `exited` | Process exited with code 0 | | `error` | Process exited non-zero | `exited`, `stopped`, and `error` are terminal and never flip back. So: > A session is done, or needs you, when its status is `awaiting_input`, `exited`, or > `stopped`. Everything else means keep waiting. **Poll the status, not the terminal.** Do not poll `lanes_read_terminal` in a tight loop. Poll the cheap, structured `lanes_get_session_status`, and read the terminal only once, when a session flips to `awaiting_input` or `error` and you need to know why. ## Patterns Each pattern below is a prompt you can paste to an agent that has the Lanes MCP connected. ### Wait until done (goal loop) ```text Start a session on Lanes issue 12 in plan mode. Poll its status every few seconds and tell me the moment it flips to awaiting_input, exited, or stopped. When it does, read the last 40 terminal lines and summarize what it is asking or what it finished. ``` ### Babysit running sessions (cadence loop) ```text /loop 5m Check every running Lanes session with lanes_get_session_status. For any that are awaiting_input or error, read the last 50 terminal lines, tell me in one line what each one needs, and stop the loop once nothing is left running. ``` ### Drive a column (scheduled loop) ```text /loop 10m Look at the implementation column in Lanes. Take the top issue that has no running session, make sure it has a worktree, and start a plan-mode session on it. When a running session reaches awaiting_input or exits, check lanes_get_issue_changes; if there is a real diff, move the issue to review. Stop the loop when the column is clear. ``` Board columns are `backlog`, `planning`, `implementation`, `review`, `done`, and `misc`. ### Drain the backlog, one PR per issue ```text Go through every issue in my Lanes backlog. Give each one its own worktree, start a session to implement it, and wait until it finishes or asks for input before starting the next. Have each session open a pull request when it is done, then move the issue to review. List the PRs at the end. ``` Two notes on this one. Lanes has no "open a PR" tool of its own: the session opens the PR itself, since it has a shell and its own worktree branch, and you can post the link back onto a linked GitHub issue with `lanes_github_comment_on_issue`. And a worktree only appears if the issue has `worktreeStrategy: create` and a `worktreeName` set before the session starts, so have the loop set those first. Drop the wait between starts to run the backlog in parallel instead; the per-issue worktrees keep the sessions from colliding. ### Fan-out with a checker ```text For each Lanes issue labelled "ready", make sure it has worktreeStrategy set to create and a worktreeName, then start a plan-mode session on each. Poll them all and give me a status table. When one produces a diff, start a second session on that same issue whose only job is to review the diff and run the tests, and report whether it passes. ``` Splitting the maker from the checker matters: a fresh session with different instructions catches what the first one talked itself into. ### Fix until green (verification loop) ```text Start a session on Lanes issue 20 to make the tests pass. When it goes idle, run the test suite and check lanes_get_issue_changes. If tests fail, resume the session with the failing output pasted in and tell it to fix exactly those. Repeat at most 3 times, then stop and report whether it is green. ``` ## A runnable driver For unattended runs, talk JSON-RPC to the local server directly. This is the shape of the loop, adapted from `core/scripts/idle-probe.ts` in the Lanes source. Note the two stopping conditions and the single verification step. ```ts const MCP = process.env.LANES_MCP_URL ?? "http://localhost:5353/message"; const POLL_MS = 2000; const TIMEOUT_MS = 15 * 60_000; // hard stop: never loop forever const DONE = new Set(["awaiting_input", "exited", "stopped"]); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let id = 0; async function call(name, args) { const res = await fetch(MCP, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }), }); const json = await res.json(); if (json.error) throw new Error(`${name}: ${json.error.message}`); const text = json.result?.content?.[0]?.text ?? ""; try { return JSON.parse(text); } catch { return text; } } async function driveIssue(issueId) { await call("lanes_start_session", { issueId, cli: "claude", planMode: true }); const started = Date.now(); while (Date.now() - started < TIMEOUT_MS) { // brake #1: timeout const sessions = (await call("lanes_get_session_status", { issueId })) ?? []; const s = sessions.find((x) => x.ptyActive) ?? sessions[0]; if (s && DONE.has(s.status)) { // brake #2: terminal status const changes = await call("lanes_get_issue_changes", { id: issueId }); return { status: s.status, changes }; } await sleep(POLL_MS); } return { status: "timeout" }; } ``` ## Guardrails - **Two brakes, always.** A wall-clock timeout and a max-iteration cap. A terminal status is a third, natural brake. Never write a loop whose only exit is success. - **Verify before you advance.** Gate `lanes_move_issue` to `review` or `done` on `lanes_get_issue_changes` plus a real test run, not on the model saying it is done. Where it matters, let a separate checker session grade the work. - **Keep a human checkpoint.** `awaiting_input` hands control back; the `review` column is where a person confirms before anything ships. - **Mind the context budget.** One issue per worktree keeps each session's context clean. Keep the orchestrator lean by polling status, not scrollback. - **Wait on events, not on sleep.** For "wake me when idle," prefer a watcher that fires on the status change over a tight polling loop. ## Further reading - [Loop Engineering: Stop Prompting, Start Looping](/blog/loop-engineering-with-lanes): the why, with the 2026 sources that defined the practice. - [Lanes Desktop MCP](/docs/desktop/local-mcp): enabling the server and the full tool surface. - [GitHub Integration](/docs/desktop/github-integration) and [Linear Integration](/docs/desktop/linear-integration): start and finish loops on your team's source of truth. Source: https://lanes.sh/docs/desktop/loops --- # Lanes Link > Run Lanes Link from the desktop app: install the CLI, choose a profile and target, connect accounts, start the endpoint, and register it with Claude Code or Codex, without leaving Lanes. [Lanes Link](/link) is one MCP endpoint you run yourself. It holds your accounts, your memory, the skills you reuse, and the credentials you would rather not paste into a prompt, and it decides what each agent may do with them. It is a separate product from the desktop app, with its own repository, its own CLI, and its own release line. Since v0.47.0, Lanes can drive it for you. The **Lanes Link** page installs the CLI, holds the profile and target every command runs against, connects accounts, starts and stops the endpoint, and registers it with Claude Code or Codex. The page is native UI over the `lanes` CLI: it runs the commands you would otherwise type, and shows you what the CLI reports back. It carries a **Research Preview** badge. The feature is new and its shape may still move. ## Where to Find It Open **Settings**, then **Integrations**, then **Lanes Link**. There is also a **Link** row in the sidebar that jumps straight there. If you already have the CLI, `lanes link desktop` opens this page from a terminal. It is a [deep link](/docs/desktop/deep-links) under the hood, and needs Lanes 0.48.0 or newer. ![The Lanes Link settings page in Lanes, showing the CLI status card with its version, the target and profile selectors, the endpoint row with its running state, and the list of connected accounts](/assets/integrations/lanes-link.png) If you are weighing Lanes Link against the other two Lanes MCP servers, [MCP](/docs/mcp) compares all three side by side. ## Installing the CLI The first card on the page is the CLI itself. It shows whether `lanes` is **On PATH**, which version you have, whether a newer one is available, where it is installed, and which package manager put it there (`bun global`, `npm global`, `Homebrew`, `native installer`, or `unrecognised install`). **Install**, **Update**, and **Recheck** sit alongside. Bun is the only installer Lanes Link supports, so **Install** runs: ```console $ bun install -g @lanes-sh/link ``` If Bun itself is missing but Homebrew is present, the same button installs Bun first. If neither is there, Lanes explains what to do instead of piping a downloaded script into your shell: > Needs Bun, which Lanes Link requires and this machine doesn't have. Install Bun from > bun.sh, then come back. Two cases the card calls out: - **You installed from a checkout.** Lanes says so, because `git pull` is the update there, not a package manager. - **Nothing else on the page appears yet.** Everything below the card stays hidden until the CLI is actually installed. There is nothing to configure without it. ### You usually will not press it Lanes runs that install for you. Installing the app installs the CLI, and updating the app updates it — so on most machines the card is already green the first time you open this page, and the buttons are there for the times it is not. It runs once per launch, and only when the app's version has changed since it last did: a fresh install, or the first launch after an update. There is no polling, and nothing reinstalls a CLI you removed on purpose until the app next updates. The command runs in a real terminal you can open from the sidebar while it works, and **Auto-Update** in Settings → General is the switch — the same one that lets the app replace itself. Two cases it leaves alone. A checkout install is never touched, because `git pull` is the update there. And a machine with neither Bun nor Homebrew has no command to run, so nothing happens and the card says what to do. If a copy installed by npm, pnpm or yarn is in the way, Lanes removes it and installs with Bun, so you are left with one `lanes` rather than two and no question about which one your shell picks. ## Choosing a Profile and Target A **profile** is one set of connections. A **target** is where that profile runs and which credential store it opens. Lanes Link picks neither for you, so the desktop app owns the pair and passes `--profile` and `--target` on every command it runs. The two dropdowns at the top of the **Connections** section are that choice. Targets read as `local (file)` or `cloud (deployed to cloudrun)`. Your selection is saved in the app's `integrations.json`, so it survives a restart. Keeping work and personal in separate profiles is the point of the design: every call an agent makes carries the profile, so the two never depend on remembering which context you are in. ## Connecting an Account Click **Add new connection**. A dialog opens with a **Filter providers** box and every provider Lanes Link supports, each showing how many accounts you already have connected to it. Pick one and Lanes runs `lanes link connect` for that provider against the selected profile and target, in a real terminal inside the app. Your browser opens for consent, and the credential is written to that target's store. Some providers have no endpoint that can say whose account a credential belongs to. For those, Lanes asks for a short name first (lowercase letters, digits, and underscores) and shows you the resulting connection key as you type, for example `gmail.main`. The exchange stays with the CLI on purpose. Lanes starts the command and shows you its output; it does not implement the OAuth flow itself, and it never sees the token. ## Reading Connection State Each connection row shows the provider, its key, the account, and a state pill: | Pill | What it means | |---|---| | `active` | Declared, and this target holds what it needs. | | `unauthorized` | Declared, but the credential is missing from this target's store. **Repair** re-runs connect. | | `not synced` | Declared, but this target's state store has not seen it yet. Starting the endpoint reconciles it. | | `disabled` | Present in the state store but no longer declared in this profile. | The row's menu offers **Repair** when there is something to repair, and **Show profile file** always. There is deliberately no Remove action: a connection is declared in the profile file, so removing one is an edit to that file, which the CLI owns. ## Starting the Endpoint The endpoint row shows the URL and whether it is answering: **Running**, **Deployed and answering**, or **Not answering**, with the profiles it serves underneath. **Start**, **Log**, **Stop**, and **Recheck** act on it. **Start** runs `lanes link start` for the current selection in a long-lived terminal that Lanes keeps around. **Log** opens that terminal so you can read it; closing the window leaves the endpoint running. **Stop** shuts it down. Two things worth knowing: - Lanes serves **one selection at a time**. If you switch profile or target while an endpoint is up, the page tells you which one it is currently running and asks you to stop it before serving the new selection. - **Deployed targets have no Start button.** A cloud target is already running somewhere else; Lanes only reports whether it answers. ## Registering an Agent The **Agent clients** section has a row per harness Lanes can register for you, Claude Code and Codex, each showing whether it is installed and whether its registration is current. The button reads **Add to Claude Code** when it is not registered, **Update files** when the skill or scout agent has gone out of date, and **Re-add** when everything is current. Running it again is safe, and worth doing when a skill is stale. Registering does three things: 1. Points the harness at your endpoint, under the server name `lanes-link`, at Claude Code's **user scope**. Your accounts are not one repository's tooling, so the registration is not bound to a project or a worktree. 2. Installs a skill at `~/.claude/skills/lanes-link/SKILL.md` that tells the agent what is behind the endpoint. 3. Installs a `lanes-link-scout` subagent at `~/.claude/agents/lanes-link-scout.md`. The secondary **No skill** button registers the endpoint without touching the agent's own skill files. Because the registration lives at user scope, every session Lanes starts picks the tools up automatically. There is nothing to switch on per session. ### Any Other Client Expand **Another client** for the two facts any MCP client needs, a URL and a bearer token: ```console $ claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \ --header "Authorization: Bearer $(lanes link token show --raw --profile personal --target local)" ``` Copy that line as it is. The `$(...)` is load bearing: it keeps the token out of the agent's context and out of your transcript. Lanes never reads the token either. ## What Lanes Does Not Do The boundary between the app and the CLI is deliberate, and worth knowing before you debug something: - **Lanes never reads your bearer token.** The command that would print it exists and is not called. - **Lanes does not write MCP config.** `lanes link mcp add` runs each harness's own registration command, because the config format is that agent's business. - **Lanes does not implement OAuth.** The CLI owns the loopback listener and the browser consent. - **Lanes does not edit your profile file.** It reads it, shows it to you, and opens it. So anything you set up here works the same from a terminal, and anything you set up from a terminal shows up here. ## Running Lanes Link On Its Own You do not need the desktop app. Lanes Link is a standalone, Apache-2.0 CLI, documented in full at [Lanes Link docs](/docs/link), with its source in [lanes-sh/link](https://github.com/lanes-sh/link): - [Quickstart](/docs/link/quickstart), from nothing to a working endpoint. - [Connections](/docs/link/connect), what a connection is and the methods behind one. - [Every provider](/docs/link/providers), all 105 and what each one needs. - [Add it to your agent](/docs/link/clients), Claude Code, Codex, Claude Desktop, claude.ai, and ChatGPT. - [Deploy to your own cloud](/docs/link/deploy), five commands to a URL. The [announcement post](/blog/introducing-lanes-link) covers the permission model, profiles, and the audit log in depth. Source: https://lanes.sh/docs/desktop/lanes-link # Lanes Forms # Quickstart > Provision a form, wire it into your site, and claim it, in three steps. Lanes Forms gives you a form backend without building one. Provision an endpoint, point your site at it, and claim it when you are ready. No signup to get started. ## 1. Provision an endpoint One `POST` returns a live endpoint. Only `schema` is required. ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Content-Type: application/json" \ -d '{ "recipients": ["you@company.com"], "allowed_origins": ["example.com"], "schema": [ {"name": "email", "type": "email", "required": true}, {"name": "message", "type": "textarea"} ] }' ``` The response includes an `endpoint_url` to submit to, and either a `claim_url` or a `claim_email_sent_to`, depending on whether you passed any `recipients`. Full detail in [Provisioning](/docs/forms/provisioning). ## 2. Wire it into your site Point a plain HTML form at the endpoint. On submit, the visitor is redirected to a hosted thank-you page. ```html
``` Prefer JSON? POST to the same endpoint from `fetch` or your server and read back `{ "ok": true, "submission_id": "..." }`. See [Integrating](/docs/forms/integrating) and [Submitting](/docs/forms/submitting). ## 3. Claim it Submissions are captured from the first second and held until you claim the form. Open the claim link (emailed to your first recipient, or returned as `claim_url`) and sign in. Everything collected so far lands in your dashboard and email forwarding turns on. See [Claiming](/docs/forms/claiming). ## Doing it from an agent If you work through a coding agent, skip the curl entirely. The Lanes MCP server and the `lanes-forms` skill both provision forms for you: ```text Set up form handling for this site using Lanes Forms. No signup is required. 1. Install the Lanes Forms skill: /plugin marketplace add lanes-sh/app /plugin install lanes-forms@lanes 2. Then use it to provision a form: "Use the lanes-forms skill to provision a form backend for submissions to from ." The skill knows the API contract, creates the form, and returns a live endpoint to point the site at. Submissions are captured from the first second. The same skill can also fill in a form on your behalf: give it a live endpoint and the fields to submit. How the form is claimed depends on how it is provisioned: - No key: the response always returns a one-time claim_url. Hand it to whoever should own the form. If you pass recipients, that same link is also emailed to the first address, so they can click either one, sign in, and claim the form to turn on forwarding. - With a workspace key (lfk_...): the form is born claimed into that workspace, no claim step needed. ``` More in [Agents and MCP](/docs/forms/agents-and-mcp). ## Next steps - [Provisioning a form](/docs/forms/provisioning): every option on the create call. - [Securing your endpoints](/docs/forms/securing-your-endpoints): origins versus API keys. - [API reference](/docs/forms/api-reference): the full endpoint list. Source: https://lanes.sh/docs/forms/quickstart --- # Provisioning a form > Create a live form endpoint with a single POST, anonymously or with a workspace key. Provisioning is how a form comes to life. One `POST /v1/forms` returns a live endpoint that starts accepting submissions immediately. There is no signup, and the same call works from a coding agent over MCP or from a raw `curl`. ## The request Only `schema` is required. Everything else is optional. ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Content-Type: application/json" \ -d '{ "recipients": ["you@company.com"], "allowed_origins": ["example.com"], "schema": [ {"name": "email", "type": "email", "required": true}, {"name": "message", "type": "textarea"} ] }' ``` | Field | Type | Notes | | --- | --- | --- | | `schema` | array | Required. One or more fields. Each has `name`, `type`, optional `required` and `max_length`. | | `recipients` | array | Addresses submissions forward to (max 5). The first is where a claim link is sent; each must be verified before forwarding reaches it. | | `allowed_origins` | array | Browser origins allowed to submit, for example `["https://example.com"]`. Empty means no browser origin is accepted. | | `name` | string | A label for the form in the dashboard. | | `workflow` | array | Delivery actions (`email`, `store`, `webhook`). Defaults to email plus Lanes storage. | | `forward_email` / `store_submissions` | boolean | Shorthand toggles when you do not want to pass a full `workflow`. | | `metadata` | object | Arbitrary string key-value pairs stored with the form. | Field types are `text`, `email`, `textarea`, `number`, `checkbox`, and `hidden`. ## What you get back A create returns `201`; see [Idempotency](#idempotency) for the one case that returns `200`. ```json { "form_id": "e5b1c0de-1a2b-3c4d-5e6f-7a8b9c0d1e2f", "endpoint_url": "https://api.lanes.sh/v1/f/e5b1c0de-1a2b-3c4d-5e6f-7a8b9c0d1e2f", "claim_url": null, "state": "unclaimed", "claim_email_sent_to": "you@company.com", "expires_at": "2026-07-13T09:00:00Z", "limits": { "unclaimed_max_submissions": 25 }, "workflow": [ { "type": "email", "enabled": true, "to": ["you@company.com"] }, { "type": "store", "enabled": true, "destination": "lanes" }, { "type": "webhook", "enabled": false, "url": null } ], "forward_email": true, "store_submissions": true, "idempotent_replay": false } ``` Point your site at `endpoint_url` and you are collecting. See [Integrating a form](/docs/forms/integrating). ## Anonymous versus keyed How you authenticate the create call decides the form's starting state. ### Anonymous (no auth) The form is born `unclaimed`. It collects right away but holds everything until someone claims it. - With one or more `recipients`, a claim link is emailed to the first one. `claim_email_sent_to` is set and `claim_url` is `null`. - Without any `recipients`, no email is sent. Instead the response returns a single-use `claim_url` for you to hand to the form's owner. ### Keyed (workspace API key) Send a workspace key and the form is born `claimed` directly into that key's workspace. No claim email is sent, no claim step is needed, and `claim_url`, `claim_email_sent_to`, and `expires_at` are all `null`. ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Authorization: Bearer lfk_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipients": ["you@company.com"], "allowed_origins": ["example.com"], "schema": [ {"name": "email", "type": "email", "required": true}, {"name": "message", "type": "textarea"} ] }' ``` See [API keys](/docs/forms/api-keys) to create one. ## Limits while unclaimed An unclaimed form is a holding pen, not a dead end: - It stores up to **25 submissions** (`limits.unclaimed_max_submissions`). - It **forwards nothing**. Email delivery turns on only when the form is claimed. - It **expires after 7 days** (`expires_at`) if never claimed, moving to the `frozen` state. **Claim to keep collecting** Claiming lifts these limits, releases every held submission into the dashboard, and turns on forwarding. See [Claiming a form](/docs/forms/claiming). ## Idempotency Every create makes a new form. Two identical calls give you two independent forms, each with its own claim link: the same schema is not the same form. To make a retry safe, send an `Idempotency-Key` header with a value you generate per call, and reuse that value if you retry: ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" \ -d '{"schema":[{"name":"email","type":"email","required":true}]}' ``` Repeating a call with the same key within 24 hours replays the original form and returns `200` with `idempotent_replay: true` instead of creating a second one. A replay never re-issues the claim link (`claim_url` is `null`, and no claim email is re-sent), so keep the link from the original response. Keys are scoped to the caller, so yours never collides with anyone else's. Keyed (`lfk_`) creates always create a new form. ## Anti-abuse The anonymous path is rate limited and gated so it cannot be used to spam inboxes: | Guard | Limit | Error | | --- | --- | --- | | Disposable email addresses | rejected | `422 disposable_email` | | Forms per IP per 24h | 20 | rate limited | | Forms per email domain per 24h | 100 | rate limited | | Pending unclaimed form for the same email | one at a time | `429 pending_claim_exists` | | Claim emails per address per 24h | 5 | rate limited | Keyed creates skip these gates, since the workspace key already establishes trust. ## Form states A form moves through a small state machine: - `unclaimed` to `claimed`: someone claims the form. - `unclaimed` to `frozen`: 7 days pass with no claim. The endpoint then returns `410`. - `frozen` to `claimed`: claiming from a reminder link restores a frozen form. - `claimed` to `deleted`: the owner deletes it. The endpoint returns `410`. - `frozen` or `deleted` to `purged`: 30 days later the data is hard-deleted. Next: [Submitting data](/docs/forms/submitting) and [Integrating a form](/docs/forms/integrating). Source: https://lanes.sh/docs/forms/provisioning --- # Claiming a form > Take ownership of an unclaimed form. Held submissions are released and forwarding turns on. Claiming is how an `unclaimed` form becomes a real, owned form in a dashboard. Everything it collected while unclaimed comes with it. ## Who can claim Possession of the claim link is the authorization. Whoever opens it and signs in becomes the owner. The account they sign in with does **not** need to match any of the form's `recipients`, which is deliberate: it lets you provision a form for a colleague and hand them the link. ## The claim link The link points at the frontend, not the API: ```text https://lanes.sh/claim/{token} ``` You get it in one of two ways, depending on how the form was [provisioned](/docs/forms/provisioning): - If the form had recipients, a claim email is sent to the first one. - If it did not, provisioning returns a single-use `claim_url` for you to deliver yourself. ## Preview before claiming `GET /v1/claim/{token}/preview` is a public, non-consuming peek. It never burns the single-use token, so an email scanner prefetching the link cannot spend it. It returns enough to render a claim page: ```json { "status": "claimable", "frozen": false, "form_name": "Contact form", "claim_email_masked": "o***@acme.com", "submissions_waiting": 3, "token_expires_at": "2026-07-20T09:00:00Z" } ``` `status` is one of `claimable`, `already_claimed`, `deleted`, or `invalid_or_expired`. ## Redeeming `POST /v1/claim/{token}` is the actual redeem. It is authenticated: the caller signs in (Firebase) and the token is consumed for that account. The redeem is race-safe and single-use, so only one caller can ever win a given token. **This runs in the browser** Claiming happens on the site, not through raw API calls you script. Send the owner to the claim link and let them sign in. There is no way to claim a form on another person's behalf through the API. ## What claiming changes A successful claim does all of this in one transaction: 1. The claimer's **default workspace and project are created** if they do not have one yet. 2. The form moves to **`claimed`**: `claimed_at` is stamped, it is filed under the workspace's project, and if it was `frozen` it is **restored**. 3. **Every held submission is released**, spam included, and becomes visible in the dashboard. 4. If the form had recipients, the first one is **pre-verified** in the new workspace, which **turns email forwarding on**. **No recipients means no forwarding yet** A form claimed with no recipients has nothing to verify, so forwarding stays off until the owner adds and verifies one in the dashboard. See [Integrating a form](/docs/forms/integrating) for how delivery works. ## After claiming The form now lives in the dashboard. From there the owner can manage recipients, origins, schema, and delivery, and can create [API keys](/docs/forms/api-keys) to manage or submit to it programmatically. Next: [Submitting data](/docs/forms/submitting). Source: https://lanes.sh/docs/forms/claiming --- # Submitting data > POST to a form endpoint from a browser, a server, or an agent, and what comes back. Once a form is [provisioned](/docs/forms/provisioning), anything can post to its `endpoint_url`. This page is the contract for that endpoint. ## The request ```text POST /v1/f/{form_id} ``` Accepted content types: - `application/json`: a flat object of field values. A non-object body is rejected with `400 invalid_body`. - `application/x-www-form-urlencoded`: standard form encoding. - `multipart/form-data` **without file parts**. A file part returns `415 files_not_supported`; there are no uploads in v1. Repeated keys collapse to a list. A request with **no `Content-Type`** is rejected with `415 unsupported_content_type` rather than being silently dropped. ```bash curl -X POST https://api.lanes.sh/v1/f/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -d '{"email": "visitor@site.com", "message": "Hello!"}' ``` ## The response The endpoint serves both machines and browsers, and it decides which you are from the `Accept` header: - **API callers** (Accept is not `text/html`) get `200` with a JSON receipt: ```json { "ok": true, "submission_id": "b7c2..." } ``` - **Browser form posts** (Accept includes `text/html`) get a `303` redirect to `/thanks/{form_id}`, so a plain HTML form lands the visitor on a thank-you page with no JavaScript. ## Fields and validation The form's `schema` defines the known fields. On submit, values are split into `data` (keys that match the schema) and `extra` (everything else). Extra keys are stored, not rejected, so adding a field to your markup will not break submissions. Required fields that are absent or empty return `422 missing_required_fields`, and the response lists exactly which fields were missing. ## The honeypot Every form reserves a field named `_gotcha`. It should be present in your markup but hidden from humans and left empty. If it arrives filled, the submission is silently marked as spam and still returns success, so a bot learns nothing. `_gotcha` is stripped before storage and cannot appear in a form's schema. ```html ``` ## Limits | Guard | Limit | Error | | --- | --- | --- | | Body size | 64 KB | `413 payload_too_large` | | Rate limit | 10 per minute per IP | `429 submission_rate_limited` | | Unclaimed cap | 25 stored submissions | `429 unclaimed_submission_cap` | **The unclaimed cap** While a form is unclaimed it stores up to 25 submissions (spam counts toward the total). At the cap, a genuine submission is refused with `429 unclaimed_submission_cap` until the owner [claims the form](/docs/forms/claiming). Over-cap spam still returns success but is not stored. ## What is stored Each stored submission keeps the `data` and `extra` field values, the request `origin`, a **salted hash of the IP** (the raw IP is never stored), a truncated user agent, a spam score, and whether it has been `released` to the dashboard. Submissions collected while unclaimed are held until a claim releases them. ## State gates If a form has been deleted or has frozen from expiry, its endpoint returns `410`. An unknown `form_id` returns `404 form_not_found`. Next: [Integrating a form](/docs/forms/integrating) for ready-made snippets, and [Securing your endpoints](/docs/forms/securing-your-endpoints) for origins and keys. Source: https://lanes.sh/docs/forms/submitting --- # Integrating a form > Wire an endpoint into a static HTML form, a fetch call, or a server, and choose where submissions go. Integrating means pointing something at the endpoint and deciding where submissions go. Pick the surface that matches your stack. ## Plain HTML form The simplest integration: set the form `action` to your `endpoint_url` and let the browser POST. Because the browser sends `Accept: text/html`, the visitor is redirected to `/thanks/{form_id}` on success. No JavaScript required. ```html
``` Keep the hidden `_gotcha` input in your markup. It is the [honeypot](/docs/forms/submitting#the-honeypot) that silently absorbs bots. ## fetch / AJAX For a custom UI, POST JSON and handle the receipt yourself: ```js const res = await fetch("https://api.lanes.sh/v1/f/YOUR_FORM_ID", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, message, _gotcha: "" }), }); const { ok, submission_id } = await res.json(); ``` **Never put an API key in browser code** Browser submissions are authorized by the form's `allowed_origins`, not by a secret. A workspace key shipped to the browser is a leaked key. Keep public, browser-facing forms in `open` mode with an origin allowlist. See [Securing your endpoints](/docs/forms/securing-your-endpoints). ## Server-side From a backend, POST JSON the same way you would with `curl`. If the form is in `api_key` mode, add the workspace key as a bearer token: ```bash curl -X POST https://api.lanes.sh/v1/f/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -H "Authorization: Bearer lfk_YOUR_KEY" \ -d '{"email": "visitor@site.com", "message": "Hello!"}' ``` ## Where submissions go Delivery is configured per form as a list of **workflow actions**. Two are live today: | Action | Status | What it does | | --- | --- | --- | | `email` | Live | Forwards each non-spam submission to your verified recipients (up to 5). | | `store` (`lanes`) | Live | Keeps submissions in Lanes-hosted storage, viewable and exportable from the dashboard. | | `store` (`custom`) | Coming soon | Your own database, such as Supabase. Returns `422 action_not_available` today. | | `webhook` | Coming soon | POST each submission to your URL. Returns `422 action_not_available` today. | ### Email forwarding Adding a recipient sends a confirmation link (`GET /v1/verify-email/{token}`). Until a recipient confirms, it is silently skipped, so unverified addresses never receive mail. When a form is [claimed](/docs/forms/claiming) with recipients, the first one is verified for you and forwarding starts. **Forwarding waits for a claim** An unclaimed form stores submissions but forwards nothing. Delivery activates the moment the form is claimed. ## The thank-you page Browser posts land on a hosted `/thanks/{form_id}` page. A per-form redirect override (sending visitors to your own thank-you URL) is on the roadmap. Next: [Securing your endpoints](/docs/forms/securing-your-endpoints) and [API keys](/docs/forms/api-keys). Source: https://lanes.sh/docs/forms/integrating --- # API keys > Workspace keys for provisioning pre-claimed forms, managing them, and server-side submissions. API keys let you drive Lanes Forms programmatically without the claim flow and without a browser session. ## What a key is - Keys are prefixed **`lfk_`** (lanes forms key) followed by a random secret. - They are **workspace-scoped**, not per-form. One key manages every form in its workspace. - Only the **sha256 hash** and a short display prefix (for example `lfk_a1b2c3d4`) are stored. The full key is shown **once** at creation and can never be retrieved again. ## Managing keys Keys are created and revoked in the dashboard. All three endpoints require a signed-in **owner** of the workspace. | Method | Path | Result | | --- | --- | --- | | `POST` | `/v1/workspaces/{workspace_id}/api-keys` | `201`, returns the plaintext `key` once. Max 10 active keys, then `429 api_key_limit_reached`. | | `GET` | `/v1/workspaces/{workspace_id}/api-keys` | Lists keys (display prefix only, never the secret or hash). Revoked keys included. | | `DELETE` | `/v1/workspaces/{workspace_id}/api-keys/{key_id}` | `204`. Soft-revokes the key; the row is kept for audit. | **Copy it now** The plaintext key appears exactly once, at creation. Store it in your secret manager immediately. If you lose it, revoke it and create a new one. ## What a key lets you do A workspace key enables three things: 1. **Provision pre-claimed forms.** Send `Authorization: Bearer lfk_...` on `POST /v1/forms` and the form is born `claimed` in the key's workspace, skipping the claim step entirely. See [Provisioning](/docs/forms/provisioning#keyed-workspace-api-key). 2. **Manage forms.** `GET` and `PATCH /v1/forms/{form_id}` and `resend-verification` accept the workspace key in place of a signed-in owner. Management applies to claimed forms only. 3. **Submit server-side.** For a form in `api_key` submission mode, server-side requests must carry the key. See [Securing your endpoints](/docs/forms/securing-your-endpoints). ## What a key cannot do - Keys do **not** authorize dashboard reads (listing submissions, workspaces, projects). Those use your signed-in session. - There is **no per-form secret**. The workspace key, or a signed-in owner, is the only programmatic path to a form. - Unknown and revoked keys both return the same `401 invalid_api_key`, so a probe cannot tell a wrong key from a revoked one. **Server-side only** An `lfk_` key is a secret. Never embed it in a browser bundle, a mobile app, or any client a user can inspect. Browser forms are secured by origin, not by keys. See [Securing your endpoints](/docs/forms/securing-your-endpoints). Next: [Securing your endpoints](/docs/forms/securing-your-endpoints). Source: https://lanes.sh/docs/forms/api-keys --- # Securing your endpoints > Origin allowlisting for browsers, API-key mode for servers, plus CORS and built-in spam defense. Lanes Forms secures a submission endpoint on two independent axes: **where the browser request came from** (origin), and **whether a server request carries a key** (submission mode). Understanding the split is the key to choosing the right setup. ## The two axes - **`allowed_origins`** governs **browser** submissions. A request that carries an `Origin` header must match the allowlist. - **`submission_auth`** governs **non-browser** submissions (a server, `curl`, or an agent, none of which send an `Origin`). It is either `open` (the default) or `api_key`, and is changed with a `PATCH` on a claimed form. The important subtlety: **`allowed_origins` applies in both modes.** Switching to `api_key` mode does not loosen or tighten browser rules; it only decides what happens to requests with no `Origin`. ## The full matrix | Caller | `open` (default) | `api_key` | | --- | --- | --- | | Browser, `Origin` in allowlist | Allowed, no credential | Allowed, no credential | | Browser, `Origin` not in allowlist | `403 origin_not_allowed` | `403 origin_not_allowed` | | Server / curl / agent (no `Origin`) | Allowed, no credential | Requires `lfk_` key, else `401 api_key_required` | | localhost origin (dev) | Allowed | Requires `lfk_` key | An empty `allowed_origins` rejects every browser origin, which is exactly what you want for a form that only your backend submits to. ## Client (browser) interaction For a public form on a website, the browser is the client and it cannot keep a secret. So the browser is authorized by its **origin**, which the browser sets and page scripts cannot forge. - Keep the form in **`open` mode**. - List the sites that may submit in **`allowed_origins`**, for example `["https://example.com"]`. - Do **not** put any key in the page. **A key in the browser is a leaked key** Anything shipped to the browser is readable by anyone. That is why browser forms rely on origin allowlisting instead of secrets. Reserve `api_key` mode for server-to-server use. ## Server-to-server interaction When only your backend submits, there is no browser and no `Origin` header. Lock the form down: - Set **`submission_auth` to `api_key`**. - Send the workspace key on every submit: `Authorization: Bearer lfk_...`. - Optionally leave `allowed_origins` empty so no browser can submit at all. See [API keys](/docs/forms/api-keys) for creating and storing the key. ## CORS CORS is handled per form, not by a global wildcard: - `OPTIONS /v1/f/{form_id}` preflight echoes the **exact** requesting `Origin` (never `*`), with `Access-Control-Allow-Methods: POST, OPTIONS` and `Allow-Headers: Content-Type, Authorization`. A preflight with no `Origin` returns `400 preflight_origin_required`; a disallowed one returns `403 origin_not_allowed`. - Every submission response for an allowed origin echoes `Access-Control-Allow-Origin` so page scripts can read success and error bodies. A disallowed origin is never echoed. ## Built-in defenses Beyond origins and keys, every form gets these for free: - **Honeypot**: the `_gotcha` field silently absorbs bots. See [Submitting](/docs/forms/submitting#the-honeypot). - **Rate limiting**: 10 submissions per minute per IP. - **Size cap**: 64 KB per request. - **Disposable-email rejection** at provisioning time. - **Unclaimed cap**: 25 submissions before a claim is required. IP addresses are stored only as a salted hash, never in the raw. Next: [Errors](/docs/forms/errors) for the full error vocabulary. Source: https://lanes.sh/docs/forms/securing-your-endpoints --- # Errors > The error envelope and the codes you will meet, with how to resolve each. Every error response uses the same envelope, so you can handle failures uniformly. ## The envelope ```json { "error": { "code": "origin_not_allowed", "message": "Origin https://evil.example is not allowed for this form. Allowed: https://example.com", "docs_url": "https://lanes.sh/docs/forms/securing-your-endpoints" } } ``` Branch on the stable `code`, not on the human-readable `message`. Messages are written to be **self-correcting**: where it helps, they include the value that failed and what was expected, so an agent can often fix the call from the message alone. ## Provisioning errors | Code | Status | Fix | | --- | --- | --- | | `disposable_email` | 422 | Use a real, non-disposable recipient address. | | `pending_claim_exists` | 429 | An unclaimed form for that email already exists. Claim or reuse it. | ## Submission errors | Code | Status | Fix | | --- | --- | --- | | `form_not_found` | 404 | Check the `form_id` in the URL. | | `unsupported_content_type` | 415 | Set a `Content-Type` (`application/json`, form-urlencoded, or multipart). | | `files_not_supported` | 415 | Remove file parts. Uploads are not supported in v1. | | `invalid_body` | 400 | Send a JSON object of field values, not an array or scalar. | | `missing_required_fields` | 422 | The response lists the missing fields. Provide them. | | `payload_too_large` | 413 | Keep the body under 64 KB. | | `submission_rate_limited` | 429 | Back off. The limit is 10 per minute per IP. | | `unclaimed_submission_cap` | 429 | The form hit 25 stored submissions. It must be [claimed](/docs/forms/claiming) to accept more. | ## Security errors | Code | Status | Fix | | --- | --- | --- | | `origin_not_allowed` | 403 | Add the origin to the form's `allowed_origins`. | | `preflight_origin_required` | 400 | The CORS preflight sent no `Origin`. | | `api_key_required` | 401 | The form is in `api_key` mode. Send `Authorization: Bearer lfk_...`. | | `invalid_api_key` | 401 | The key is unknown or revoked. Create a new one. | | `api_key_limit_reached` | 429 | A workspace allows 10 active keys. Revoke an unused one. | | `api_key_not_found` | 404 | The key id does not belong to this workspace. | ## Delivery errors | Code | Status | Fix | | --- | --- | --- | | `action_not_available` | 422 | Webhook and custom (non-Lanes) storage are not live yet. Use email plus Lanes storage. | State-gate responses (`410`) are returned when a form is `frozen` or `deleted`; see [Provisioning](/docs/forms/provisioning#form-states). Next: [API reference](/docs/forms/api-reference). Source: https://lanes.sh/docs/forms/errors --- # API reference > Every endpoint at a glance, plus the live interactive OpenAPI docs. The complete, always-current contract (request bodies, response shapes, every field) lives in the interactive OpenAPI docs served by the API itself: - Interactive docs: `https://api.lanes.sh/docs` - OpenAPI schema: `https://api.lanes.sh/openapi.json` This page is the map. The base URLs: | Surface | URL | | --- | --- | | API (production) | `https://api.lanes.sh` | | Frontend (claim, dashboard) | `https://lanes.sh` | | API (local) | `http://localhost:8000` | ## Public endpoints No signup. Safe to call from an agent or `curl`. | Method | Path | Purpose | | --- | --- | --- | | `POST` | `/v1/forms` | [Provision](/docs/forms/provisioning) a form. Anonymous, or `lfk_` key for born-claimed. | | `POST` | `/v1/f/{form_id}` | [Submit](/docs/forms/submitting) to a form. | | `OPTIONS` | `/v1/f/{form_id}` | Per-form [CORS](/docs/forms/securing-your-endpoints#cors) preflight. | ## Form management Claimed forms only. Authorize with a workspace [API key](/docs/forms/api-keys) or a signed-in owner. | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/v1/forms/{form_id}` | Form config, counters, unverified recipients. | | `PATCH` | `/v1/forms/{form_id}` | Update name, origins, schema, `submission_auth`, `recipients`, workflow. | | `POST` | `/v1/forms/{form_id}/recipients/resend-verification` | Resend a recipient confirmation email. | ## Claim and verification | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/v1/claim/{token}` | Redirects to the frontend claim page. | | `GET` | `/v1/claim/{token}/preview` | Public, non-consuming status of a claim link. | | `POST` | `/v1/claim/{token}` | Redeem the claim (signed in). | | `GET` | `/v1/verify-email/{token}` | Confirm a forwarding recipient. | ## Dashboard Authorized by your signed-in session, not by API keys. | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/v1/me` | Current user. | | `GET` | `/v1/workspaces` | Your workspaces. | | `GET` | `/v1/workspaces/{id}/forms` | Forms in a workspace. | | `POST` | `/v1/workspaces/{id}/forms` | Create a form (born claimed). | | `GET` | `/v1/forms/{id}/submissions` | Paginated submissions. `?format=csv` exports. | | `DELETE` | `/v1/forms/{id}` | Soft-delete a form. | | `POST` `GET` `DELETE` | `/v1/workspaces/{id}/api-keys` | Manage [API keys](/docs/forms/api-keys) (owner only). | ## Health and pages | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/health` | Liveness check. | | `GET` | `/thanks/{form_id}` | Hosted thank-you page for browser posts. | Every error uses the shared envelope documented in [Errors](/docs/forms/errors). Source: https://lanes.sh/docs/forms/api-reference --- # Agents and MCP > Provision and fill forms from a coding agent with the MCP server, the skill, or a prompt. Lanes Forms is designed to be used by coding agents, not just humans. An agent can provision an endpoint, wire it into a site, and even fill a form in on your behalf. There are three ways to connect one. ## MCP server Add the Lanes MCP server to your agent, then ask it to provision or submit. ```text Add the Lanes MCP server to your agent, then ask it to create_form or submit_form. Nothing to install, no key needed. claude mcp add --transport http lanes https://api.lanes.sh/mcp { "mcpServers": { "lanes": { "url": "https://api.lanes.sh/mcp" } } } ``` It exposes five tools: | Tool | What it does | | --- | --- | | `create_form` | [Provision](/docs/forms/provisioning) a form. | | `get_form` | Read a form's config and counters (needs a workspace key). | | `update_form` | Patch a claimed form (needs a workspace key). | | `generate_form_snippet` | Emit a ready-to-paste HTML or React form. | | `submit_form` | [Submit](/docs/forms/submitting) to a live endpoint. | Pass a workspace [API key](/docs/forms/api-keys) (`lfk_...`) as an `Authorization: Bearer` header so that the management tools work and provisioned forms are born claimed into that workspace. ## Claude Code skill The `lanes-forms` skill bundles the API contract, so the agent just calls it. ```text Install the Lanes Forms skill, then ask your agent to provision a form or fill one in (the skill knows the API and calls it for you). /plugin marketplace add lanes-sh/app /plugin install lanes-forms@lanes Then tell your agent: "Use the lanes-forms skill to provision a form backend for submissions to you@company.com from example.com." Or point it at a live endpoint to fill a form on your behalf. ``` ## A copy-paste prompt No MCP or skill? Hand any agent this brief: ```text Set up form handling for this site using Lanes Forms. No signup is required. 1. Install the Lanes Forms skill: /plugin marketplace add lanes-sh/app /plugin install lanes-forms@lanes 2. Then use it to provision a form: "Use the lanes-forms skill to provision a form backend for submissions to from ." The skill knows the API contract, creates the form, and returns a live endpoint to point the site at. Submissions are captured from the first second. The same skill can also fill in a form on your behalf: give it a live endpoint and the fields to submit. How the form is claimed depends on how it is provisioned: - No key: the response always returns a one-time claim_url. Hand it to whoever should own the form. If you pass recipients, that same link is also emailed to the first address, so they can click either one, sign in, and claim the form to turn on forwarding. - With a workspace key (lfk_...): the form is born claimed into that workspace, no claim step needed. ``` ## Agent-friendly docs Every page here is written to be read by agents: - Each article has a **Copy for agent** button and a **Copy as Markdown** button at the top. - Any page is available as raw Markdown at its path with a `/raw` suffix, for example `https://lanes.sh/docs/forms/provisioning/raw`. - The whole documentation set is at [`/llms-full.txt`](/llms-full.txt), and the index is at [`/llms.txt`](/llms.txt). Next: [Provisioning](/docs/forms/provisioning) to see the create call in full. Source: https://lanes.sh/docs/forms/agents-and-mcp --- # Rate limits > The caps that govern provisioning and submission, and the response headers that report live quota so you can self-throttle. Lanes Forms tells you where you stand instead of making you find out by being refused. Every response on a throttled path carries its quota policy, and where the server has actually counted, the live remaining quota too. ## The headers ```http RateLimit-Policy: "submissions";q=10;w=60 RateLimit: "submissions";r=7;t=42 RateLimit-Limit: 10 RateLimit-Remaining: 7 RateLimit-Reset: 42 ``` `RateLimit-Policy` and `RateLimit` are the fields from the IETF `draft-ietf-httpapi-ratelimit-headers` specification. In `RateLimit-Policy`, `q` is the quota and `w` is the window in seconds. In `RateLimit`, `r` is the quota you have left and `t` is the seconds until the window resets. The quoted name ties the two together, so a response can carry more than one policy without ambiguity. `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` are the older spelling of the same three numbers. The draft supersedes them, but plenty of clients only understand those, so both are sent. **Read the headers, do not hard-code the numbers** The values below are the defaults. The headers are authoritative, and they are what an agent should pace itself against. ## On a 429 Every `429` carries `Retry-After` with a number of seconds: ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 Content-Type: application/json {"error": {"code": "submission_rate_limited", "message": "...", "docs_url": "..."}} ``` Wait that long, then retry. If a response carries both `Retry-After` and `RateLimit`, `Retry-After` wins. For a limit measured over 24 hours, `Retry-After` is capped at one hour rather than reporting the full window. Telling a caller to sleep for a day is not actionable, and retrying after an hour costs one refused request and gets you a fresh number. ## The documented limits | Limit | Default | |---|---| | Submissions per minute, per IP, per form | 10 | | Submissions held on an unclaimed form | 25 | | Bytes per submission | 65,536 (64 KB) | | Bytes per request body | 1,048,576 (1 MB) | | Forms created per IP per 24h | 20 | | Forms created per email domain per 24h | 100 | | Claim emails per address per 24h | 5 | | Active API keys per workspace | 10 | | MCP requests per minute, anonymous | 60 | | MCP requests per minute, with a workspace key | 300 | Exceeding one of these returns a `429` with a specific `code`: `submission_rate_limited`, `unclaimed_submission_cap`, `form_creation_rate_limited`, `domain_rate_limited`, `claim_email_rate_limited`, `pending_claim_exists`, or `api_key_limit_reached`. Each is listed with its fix in [Errors](/docs/forms/errors). Anonymous form creation also has a service-wide ceiling that answers `503` `anonymous_provisioning_paused` rather than `429`. It is a temporary pause, not a per-caller throttle, so it is not something backing off harder will clear. ## Staying under them - Read `RateLimit-Remaining` and slow down before it reaches zero, rather than sprinting into a `429`. - Use a workspace API key. It raises the MCP transport limit from 60 to 300 requests per minute and provisions forms already claimed, skipping the anonymous-path gates. - Batch nothing that does not need batching. The per-IP submission limit is per form, so spreading real traffic across forms is fine; hammering one is not. - Quote `x-request-id` from the response if you believe a limit is wrong. Source: https://lanes.sh/docs/forms/rate-limits # Lanes Link # Quickstart > From nothing to a working endpoint, in four commands From nothing to an endpoint your agents can use. Steps 1 to 5 need no external service, no credentials, and no account anywhere. The first thing that costs any setup is step 6. You need [Bun](https://bun.com) 1.3.11+. There is no build step. ## 1. Install ```console $ bun install -g @lanes-sh/link $ lanes --version # confirms it landed on your PATH ``` Check that second line. Several registration commands read your token with `$(lanes link token show --raw)`, and without `lanes` on your `PATH` that substitutes to an empty string. The only symptom is a 401 that looks like a bad token. Later, `lanes link update` installs a newer release, and `lanes link update --check` only says whether there is one. ## 2. Sign in Every human caller signs in with Lanes, on your own machine as much as on a deployed endpoint. A profile declares who may consume it, and there is nothing to check that against if the endpoint has no idea who is asking. ```console $ lanes auth login ok signed in as you@example.com subject lanes:3QBmAxJLLrYSMTVUIeCN1SKFbdD3 ``` The network is needed to sign in and to refresh, not per call. A machine offline for a day keeps serving, and `lanes auth status` says how long that has left to run. ## 3. Create a profile A profile is a selection: which of your connections it includes, what it allows on each, and who may consume it. Most people start with one. ```console $ lanes link profile add personal --workspace local ok created profile personal config ~/.lanes-link/profiles/personal.yaml port 7337 workspace local $ lanes link profile members add --me --profile personal --workspace local ok you may now consume personal as owner ``` That second command is not optional. **Empty members means nobody**, which is the opposite of how a blank list reads, so the profile you just created reaches no one until you are on it. ## 4. Your own context is already there Memory, tasks, assets, skills, and the vault hold your material rather than an account, so they need no credential, no browser, and no command. The profile you just created declares all six. Which store a thing goes in is worth knowing, because nothing refuses the wrong choice: | | | |---|---| | **memory** | what is *true*: a fact, a preference, how something works | | **tasks** | what is to be *done*, each with a status, so it can be closed | | **assets** | a *file*, kept under its own name | "Remember to chase the invoice" is a task, not a memory entry. Your agents are told this as well. Try it: ```console $ lanes link tasks add "chase the invoice" --due 2026-09-01 --profile personal --workspace local ok added task chase-the-invoice $ lanes link tasks list --profile personal --workspace local Tasks (1 outstanding) chase-the-invoice open chase the invoice due 2026-09-01 ``` [Built-ins](/docs/link/built-ins) covers all of them, and how to make memory read-only. ## 5. Start the endpoint ```console $ lanes link start --profile personal --workspace local profile personal workspace local ~/.lanes-link + memory.main create (active) + tasks.main create (active) + assets.main create (active) + skills.main create (active) + vault.main create (active) + setup.main create (active) ok reconciled warn minted a token. Run: lanes link outputs --show ok serving http://127.0.0.1:7337/mcp profiles: personal Ctrl-C to stop. ``` Leave it running. The token minted on that first start is for CI, which has no browser: your agents sign themselves in instead, and the next step is what points them here. Every command prints the profile and workspace it resolved before it acts. That first line is how you know you are operating on the instance you meant, and it is worth reading when you have set a default with `lanes set-workspace`. A **workspace** holds your connections and your profiles, and decides which stores open them. `local` keeps credentials in an encrypted file and data in a directory; a deployed workspace keeps them in your cloud instead. `lanes link workspace list` shows what you have. ("Target" was the word for this until 0.8.0. `--target` still works and warns.) ## 6. Register it with your agents In another shell: ```console $ lanes link mcp add --profile personal --workspace local ok registered lanes-link with Claude Code (user scope) installed skill at ~/.claude/skills/lanes-link/SKILL.md installed scout agent at ~/.claude/agents/lanes-link-scout.md ok registered lanes-link with Codex installed skill at ~/.codex/skills/lanes-link/SKILL.md ``` With no argument that covers every agent installed, or name one: `claude`, `codex`. One endpoint, one token, every profile, so you register once per agent rather than once per account. Codex needs one more line, and Claude Desktop is set up by hand because it cannot be given a URL. See [Add it to your agent](/docs/link/clients). ## 6. Connect your first account A mail or calendar account is the first thing that costs any setup: ```console $ lanes link connect gmail --profile personal --workspace local ``` That opens a browser and nothing else. Google authorises against the OAuth client Lanes operates, so there is no Cloud project to create. The connection is served straight away: `connect` tells the running endpoint to re-read its config, so there is nothing to restart. If no endpoint was running, the connection is saved and served when you next start one. See [Connect your accounts](/docs/link/connect) for everything you can connect and what each one needs. ## 7. Check what you have ```console $ lanes link status --profile personal --workspace local # connections, capabilities, endpoint $ lanes link audit tail --profile personal --workspace local --limit 25 # what your agents have actually done $ lanes link policy list --profile personal # what they are allowed to do ``` Ask an agent to search your mail. If it is refused, that is the permission system working. Run `lanes link policy list` and widen it deliberately. ## Two profiles side by side Work and personal never share a credential store, a state store, or an audit log: ```console $ lanes link profile add work --workspace local $ lanes link connect notion --profile work --workspace local $ lanes link start --profile personal --workspace local ok serving http://127.0.0.1:7337/mcp profiles: personal, work ``` One endpoint serves both, and each call names the profile it means. They do share that endpoint's token, so an agent holding it can reach either by asking. If you need a boundary that holds against the agent itself, use a second workspace. --- **Next:** [Connect your accounts](/docs/link/connect). Working on Lanes Link itself rather than using it? [Local development](/docs/link/local-development) has the checkout. Source: https://lanes.sh/docs/link/quickstart --- # Add it to your agent > Claude Code, Codex, Claude Desktop, claude.ai, ChatGPT, and anything else One endpoint, one token, every profile. You register once per agent, not once per account. ```console $ lanes link mcp add --profile personal --workspace local ``` With no argument that covers every agent it finds. It runs each one's own `mcp add` rather than writing to its config file, because the registration format is the agent's business. `lanes link mcp list` shows where you are registered. | Client | Local | Self-Hosted | |---|---|---| | [Claude Code](#claude-code) | `lanes link mcp add claude` | `lanes link mcp add claude --workspace cloud` | | [Codex](#codex) | `lanes link mcp add codex` | `lanes link mcp add codex --workspace cloud` | | [Claude Desktop, Cowork](#claude-desktop-and-cowork) | by hand, see below | it cannot be given a URL | | [claude.ai, ChatGPT, a phone](#claudeai-chatgpt-and-your-phone) | nothing there reaches your machine | a custom connector, by URL | | [Anything else](#anything-else) | `lanes link outputs` | `lanes link outputs --workspace cloud` | ## What the agent is told it is for A registered endpoint is sixty tools and no account of what they are collectively for. Two things fix that, and both are automatic. **Every client gets the short version from the endpoint itself**, generated per connection from what that client can actually reach, so it names your profiles and your connections and cannot go stale. **Claude Code and Codex also get a skill file**, written by `lanes link mcp add` because neither has a `skill add` command to delegate to: | | Claude Code | Codex | |---|---|---| | skill | `~/.claude/skills/lanes-link/` | `~/.codex/skills/lanes-link/` | | scout agent | `~/.claude/agents/` | no subagents | Re-run `mcp add` after an upgrade to refresh them. It reports `unchanged` when there was nothing to do, and `--no-skill` registers without writing anything. ## Claude Code ```console $ lanes link mcp add claude --profile personal --workspace local ``` That registers at user scope, because your accounts are not one repository's tooling. Claude Code stores the token as a value, so after `lanes link token rotate` run `mcp add` again with `--force`. ## Codex ```console $ lanes link mcp add codex --profile personal --workspace local $ export LANES_LINK_TOKEN="$(lanes link token show --raw)" # put this in your shell profile ``` Codex stores the *name* of an environment variable and reads it at launch, so the token never reaches `~/.codex/config.toml` and a rotation needs no re-registration. Nothing works until that export is somewhere Codex will see it. ## Claude Desktop, and Cowork Desktop cannot be pointed at a URL. Its config validates every entry against `{ command, args?, env? }`, and an entry carrying a `url` is silently dropped on launch. So Desktop spawns the endpoint instead of connecting to it: ```json { "mcpServers": { "lanes-link": { "command": "/Users/you/.bun/bin/lanes", "args": ["link", "mcp", "stdio", "--profile", "personal", "--workspace", "local"] } } } ``` That goes in `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, or `%APPDATA%\Claude\claude_desktop_config.json` on Windows, beside whatever the file already holds. Restart Desktop and it appears under Settings, Developer. One entry covers Cowork too. Both flags are required. This client spawns the endpoint rather than being pointed at a URL, so its config file is the only place that can say which profile and workspace it serves. An entry without them fails to start, and the reason appears in the client's MCP log. Two things to get right: - **Use an absolute path.** `which lanes` prints it. - **`bun` must be on the `PATH` the client passes down.** `lanes` runs behind `#!/usr/bin/env bun`, so an absolute path to it is not enough on its own. Claude Desktop passes a `PATH` that includes `~/.bun/bin`, so the entry above works as written. If a client fails with `env: bun: No such file or directory`, name Bun yourself as the `command` and pass the CLI path as the first argument. Three consequences of Desktop spawning the process: - **No token needed.** The process is a child of the client, running as you, with no port for anyone else to reach. - **`lanes link start` is not needed for it.** Run the endpoint for the clients that use HTTP. - **The tool list is fixed for the session.** A skill added while Desktop is running appears next time it starts. ## claude.ai, ChatGPT, and your phone These need a [deployment](/docs/link/deploy). There is no address they can be given that reaches a laptop. Add a custom connector by URL, using the address `lanes link outputs --workspace cloud` prints. The client registers itself, a browser opens on **lanes.sh**, you sign in and approve, and the client comes back with a token of its own. Same flow on a laptop and on a phone. **You no longer paste anything.** Until 0.8.0 the endpoint served its own approval page and asked for its bearer token, which proved possession of a secret rather than identity, and the most common failure was pasting the token of the wrong workspace. Now the endpoint asks Lanes who you are, and what it checks is whether a profile lists you as a member. `cloud` in these commands is a workspace name, whatever your deployment is called. `lanes link workspace list` shows yours. **If the endpoint refuses you after you sign in**, it is not a credential problem: no profile there lists your subject. The page says so and gives the command its owner runs, `lanes link profile members add --profile `. If that is you, run it and reconnect. ### ChatGPT Its connector UI is off by default, and the setting is not where an older walkthrough will send you: Connectors was renamed Plugins, so there is no longer a Connectors, Advanced to find. 1. **Settings → Security and login → Developer mode**, on. Plus, Pro, Business, Enterprise or Edu, and web only. A free account cannot. 2. **Plugins → `+` → New Plugin.** Name it; the icon and description are optional. 3. **Connection** is **Server URL**. Tunnel is for a server on your own machine, which this is not. Paste the `/mcp` address, with the path: it is what the endpoint names as the protected resource, and the bare origin is a different string. 4. **Authentication** is **OAuth**. There is nowhere to paste a bearer token, and *No authentication* gets a 401. 5. **Open Advanced OAuth settings.** Discovery runs from this panel, and leaving it unopened is enough to make Create do nothing at all. Choose **dynamic client registration**, leave the client id and secret empty, and take `mcp offline_access` as the scopes. 6. Tick the risk checkbox and **Create**, then connect. Your endpoint's own approval page opens, naming `chatgpt.com` as where the code goes. That is worth recognising, since registration is open by design and a client may call itself anything. Paste the token. 7. Enable the plugin in the composer. Individual tools can be switched off on its own page. Nothing has to be entered by hand: `offline_access` is what keeps the connector signed in, and this endpoint registers clients dynamically, so there is no OAuth client to create and no redirect URI to register anywhere. **After `lanes link connect` adds an account, refresh the plugin.** The endpoint is stateless, so it sends no notification that the tool list changed, and a client that does not ask again keeps the list it first saw. A connected account that never appears is this, not a broken deployment. ### When a working connector drops A connector that was working and then reports the server as unreachable, or asks to be authorised again, is one of five things. Your endpoint's own log for the minute it happened separates them: | In the log | What it was | |---|---| | `rejected request {"reason":"missing"}` | The client sent no credential. It discarded its own, or is starting discovery | | `rejected request {"reason":"invalid"}` | It sent one this endpoint does not know | | `warn refresh token replayed` | A second copy of the client presented a spent refresh token. Refused, and the live session is untouched | | A browser prompt with no `/token` line | The client's refresh failed at the network level, and the error was swallowed rather than surfaced | | **Nothing at all** | The call never left the client | The last row is worth knowing, because from the outside it looks exactly like the others and it is the only one where the endpoint is not involved. No line means no request: no cold start, no timeout, no refusal. The connector decided by itself that this endpoint was unavailable. Reconnect it; there is nothing here to change. A deployment scaling to zero is not this. It shuts down and restarts many times a day, requests queue behind the boot, and a cold start is a couple of seconds, visible in the latency column rather than as a failure. ## Anything else ```console $ lanes link outputs --profile personal --workspace local --show Endpoint http://127.0.0.1:7337/mcp running Token llk_… ``` Any client that speaks streamable HTTP MCP with a static `Authorization: Bearer` header can be pointed at those two values. `--workspace cloud` prints the deployed pair instead. --- **Next:** [Deploy to your own cloud](/docs/link/deploy), which is what claude.ai, ChatGPT, and your phone need. Source: https://lanes.sh/docs/link/clients --- # Deploy to your own cloud > Five commands to a URL you can reach from a phone Deploy when you want to reach your endpoint from something that is not your laptop: claude.ai, ChatGPT, or a phone. The same code and the same config run in both places. A workspace names a set of storage adapters, and that is the only difference. Locally, a directory and an encrypted file. Deployed, one bucket and Secret Manager. Connections, providers, permissions, and limits are declared once and apply to both. ## What you need - A Google Cloud billing account. - The [`gcloud` CLI](https://cloud.google.com/sdk/docs/install), authenticated with `gcloud auth login && gcloud auth application-default login`. That is the list. Note what is not on it: a project. There is no database to provision and no key pair to mint in a console. The first `deploy` creates the project, links billing, enables the APIs, mints the service account and the bucket, and builds the image. ## The four commands ```console $ lanes link deploy --profile personal --workspace cloud --dry-run # every gcloud command, none of them run $ lanes link deploy --profile personal --workspace cloud # creates the project and rolls a revision $ lanes link connect gmail --profile personal --workspace cloud # a browser consent per account $ lanes link outputs --profile personal --workspace cloud # the URL your agent needs ``` The order matters. `connect` comes *after* the deploy, because a credential store that does not exist yet is not somewhere to write a credential. And `outputs` comes after both, so register your agent last: a client reads the tool list when it connects and keeps it, so one registered before the accounts holds a list without them and has to be removed and re-added. There is no second deploy. `connect` copies the config to where the running revision reads it and asks that revision to re-read it, so the account is reachable as soon as the browser consent is done. Deploying is how new code gets there, and authorising an account changes no code. `--workspace cloud` is on every one of them because deploy is one of the commands that refuses a default. `lanes set-workspace` writes one and most commands use it, but publishing to the wrong place is the failure that flag exists to prevent. There is no environment variable to leave unset and nothing to forget: a command without it refuses rather than acting on `local` and leaving the deployment without the account. `cloud` there is a name rather than a keyword. It is what the first deploy calls the workspace it creates, and `lanes link workspace list` shows what your profile declares. Already built a workspace locally? `lanes link secrets push --from local --to cloud` migrates it instead of the `connect` step. It copies and never deletes, and skips anything the destination already holds unless you pass `--overwrite`. ## Two answers in the first run decide whether an agent can reach it `deploy` asks a short set of questions and writes the config from your answers. Two of them matter more than the rest. **Who may reach it.** The default, `iam`, has Cloud Run demand a Google-signed identity token before the request reaches Lanes Link. No agent can mint one, so an MCP client sees nothing but 403s. If you intend to reach it from an agent, answer **public**. The endpoint is still gated; the gate is this application's bearer token rather than the platform's front door. **Whether you will add it to Claude or ChatGPT.** Answering yes is what lets the endpoint issue its own tokens. Without it, a remote connector has no way to obtain one. There is nothing to set up for this: no OAuth client, no console, no redirect URI. Both are editable afterwards, but a wrong answer here presents as "the server is unreachable" rather than as a configuration error, so it is worth getting right the first time. ## Then register it ```console $ lanes link mcp add claude --profile personal --workspace cloud ``` Each workspace has its own credential store, so the deployed token is a different string from the local one. For claude.ai, ChatGPT, or a phone, add a custom connector by URL. See [Add it to your agent](/docs/link/clients#claudeai-chatgpt-and-your-phone). ## Watching it from the dashboard A deployed endpoint reads back into the [Lanes dashboard](https://lanes.sh/dashboard/link) the same way a local one does, so you can see what it holds without a terminal: ```console $ lanes link pair --workspace cloud ``` No certificate is installed for this — the platform already terminates TLS with one browsers trust — so the command writes a single credential and prints a link carrying it and the endpoint's address. Open it in any browser, on any machine. The credential reads every connection, profile and audit entry in the workspace and can change nothing. Take it back with `lanes link pair --workspace cloud --rotate`; a rotation takes up to five seconds to be refused, because the endpoint caches what it read rather than calling Secret Manager on every request. The dashboard holds several pairings at once, so a deployment and the laptop beside it both appear in its switcher. ## A second deployment Name it, and everything downstream takes the same flag: ```console $ lanes link deploy --profile personal --workspace staging # its own project, bucket, and service $ lanes link connect gmail --profile personal --workspace staging # same ordering: accounts before the URL $ lanes link outputs --profile personal --workspace staging ``` Every command that acts on one account names its profile and workspace, and nothing else supplies them. No environment variable, no key in a file. That is deliberate: a selection you did not type is one you cannot check. A shell alias is the way to shorten it, and it is yours to write. `deploy`, `status`, and `sync workspaces` are the exception, and not because the rule is relaxed for them. Their subject is the endpoint, which serves every profile in the workspace, so they name the workspace and `--profile` narrows what they act on rather than selecting it. ## Which profiles it serves `lanes link deploy --workspace cloud` sends **every profile that declares `cloud`**, in one revision, and the endpoint serves all of them under one token. That set is the same one the endpoint will try to open, which is why it is derived rather than typed. ```console $ lanes link deploy --workspace cloud # every profile declaring it $ lanes link deploy --workspace cloud --profile personal # only this one ``` A first deploy is different: a workspace no profile declares yet has no set to derive, so name the profile it belongs to and `deploy` creates the workspace in it. Two things it will not decide for you. **Which profile owns the endpoint's token**, because one token reaches every profile behind it, so you are asked once and the answer is remembered. And whether the profiles can **share a credential store** at all: references are flat, so two profiles in one project both declaring `gmail/main` are declaring the same secret, and `deploy` refuses rather than letting the second overwrite the first. Give the connections different ids, or use a second workspace in its own project. ## If the workspace goes missing from your profile The six lines declaring `cloud` in your profile are the only thing in the workspace pointing at the service, the bucket, and the credential store. Edit that file badly, or let a tool rewrite it, and every command reports the workspace as undeclared while the deployment carries on answering. Nothing is lost. The bucket still holds the profile exactly as the last deploy left it, and `sync workspaces` merges the two copies back together: ```console $ lanes link sync workspaces --workspace cloud --dry-run # see what each side is missing $ lanes link sync workspaces --workspace cloud personal ← workspaces.cloud missing locally ← auth.authorization missing locally ← connections.gmail.work missing locally ``` It merges in both directions and never overwrites: anything one side is missing is copied to it, and anything both sides hold differently stops the run and prints the difference. `--prefer local` or `--prefer remote` decides those. Credentials, state, and the audit log are never copied. If you have lost the workspace entirely, `--discover` asks the platform, and is the only route that works from nothing. ## Storage is not optional up here State, the audit log, memory, tasks, assets, skills, entities, and your own provider manifests all live in the bucket. A container filesystem loses every one of them on an instance recycle without reporting anything, so the deploy configures the bucket for you rather than leaving it to a flag. A bucket it creates has Autoclass on, so an asset you have not opened in a year costs archive rates without you writing a lifecycle rule. Reading it back is free and immediate, which is the part that makes a cheaper class safe to sit under your own files. --- **Next:** [Deploying to Cloud Run](/docs/link/deployment-cloudrun) covers cold starts, scaling, what the service account is granted and why, the image, using an identity provider you already run, and troubleshooting. Source: https://lanes.sh/docs/link/deploy --- # Built-ins > Memory, tasks, assets, skills, identity, entities, the vault, and the audit log. Your own material, with no account to connect and no credential to hold. Not everything your agents reach is an account. Some of it is yours: what you know, what you have to do, the files you keep, the procedures you have written down, who you are, who everybody else is, and the secrets that make the rest work. These have no OAuth, no vendor, no rate limit somebody else imposes, and nothing to connect. A new profile arrives with them already granted, because there was never anything for a connect step to authorise. ```console $ lanes link check --profile personal --workspace local ``` ```yaml connections: - { id: main, provider: memory, account: Memory } - { id: main, provider: tasks, account: Tasks } - { id: main, provider: assets, account: Assets } - { id: main, provider: skills, account: Skills } - { id: main, provider: vault, account: Vault } - { id: main, provider: entities, account: Entities } ``` ## What each one is for | | | | |---|---|---| | [Memory](/docs/link/memory) | what is **true** | a fact, a preference, how something works | | [Tasks](/docs/link/tasks) | what is **to be done** | each with a status, so it can be closed | | [Assets](/docs/link/assets) | a **file** | kept under its own name | | [Skills](/docs/link/skills) | a **procedure** | your own, invoked by you rather than chosen by a model | | [Identity](/docs/link/identity) | **who you are** | so an agent can write as you and not as itself | | [Entities](/docs/link/entities) | **who everyone else is** | so an address is looked up, not recalled | | [Vault](/docs/link/vault) | a **credential** | used to do the thing that needs it, never quoted back | | [Audit](/docs/link/audit) | what **happened** | every call, allowed or refused | One more, `setup`, is the read-only surface an agent reads to tell you what you would have to run to connect something. It has no write bundle at all, and a test asserts that. See [Connections](/docs/link/connect) for what it answers. ## Which store a thing goes in Worth knowing, because nothing refuses the wrong choice. "Remember to chase the invoice" is a task, not a memory entry. Written into memory it becomes a note nothing can ever close, served back to every later session as something that is true, so the invoice is still being chased months after it was paid. The distinguishing property is state. A fact does not finish, so memory has no status field, and that is exactly why a task could not simply be a memory entry with one. Your agents are told this too, in the endpoint's own instructions. "Jan's address is jan@acme.test" is the same mistake in a different direction. As a memory entry it is findable only by substring and comes back as prose a model has to read; as an entity it is a field, and a lookup either answers or says it cannot. ## Every one has a CLI The same bytes your agents reach, reachable by you: ```console $ lanes link memory search "postgres" --profile personal --workspace local $ lanes link tasks add "chase the invoice" --due 2026-09-01 --profile personal --workspace local $ lanes link assets list --profile personal --workspace local $ lanes link skills add ./release-checklist.md --profile personal --workspace local $ lanes link identity list --profile personal $ lanes link entities find Jan --profile personal --workspace local $ lanes link vault set stripe-key --profile personal --workspace local $ lanes link audit tail --limit 25 --profile personal --workspace local ``` Without this, the stores holding your own data would be reachable only by an agent, which is the wrong way round. ## Where it all lives Everything follows the workspace. Locally that is files under your workspace; on a deployed workspace it is the same keys in your bucket. Memory, tasks, skills and entities are plain Markdown files with YAML frontmatter, and the directory is one you are invited to open: a file you drop in by hand is an entry, and an edit you make in a text editor is what the next read returns. There is no index, deliberately, because an index would be a second copy of the truth that could disagree with the file it described. Entities is the one exception, and it earns it: a lookup that read every file would be a thousand requests against a bucket. Its `_index.json` is a cache stamped with a fingerprint of the files it was built from, so a file you edit by hand invalidates it and the next read rebuilds. It cannot quietly disagree. The vault is different. It is one encrypted document under its own key, and it is never kept in a repository. ## Nothing crosses a profile Everything belongs to the profile you stored it under. A second profile starts empty and stays that way, and nothing you store in one is visible from another. That is also the only way to give one agent a narrower view than another, because [policy names capabilities rather than connections](/docs/link/scopes). ## Turning one off Deleting a connection row does not switch a store off: the next `start`, `connect`, or `deploy` puts it back. A `deny` is how one stays off. ```console $ lanes link policy deny 'memory.write' 'memory.forget' --profile personal --workspace local ``` Name every capability that changes something, or the surface is not read-only: `memory.write` alone leaves `memory.forget` granted, and entities takes three. [Making memory read-only](/docs/link/memory) is the one worth a decision rather than a preference, with [entities](/docs/link/entities) close behind it. --- **Next:** [Memory](/docs/link/memory), or [the audit log](/docs/link/audit) for what is recorded when any of this is touched. Source: https://lanes.sh/docs/link/built-ins --- # Memory > What is true, kept as plain Markdown and served back to every later session. How to write it, how to search it, and why you may want it read-only. Memory holds what is **true**: a fact, a preference, how something works. It is served back to every later session, on every client you have registered, so a thing said once does not have to be said again. It has no status field. A fact does not finish, which is the whole reason [tasks](/docs/link/tasks) are a separate store rather than memory entries with a checkbox. ## What your agent can do | Read | Write | |---|---| | `memory.get`, `memory.search` | `memory.write`, `memory.forget` | Entries are also readable as an MCP *resource* at a stable address, `memory://entry/{id}`, for clients that read those. ## Searching it There is no index. One Markdown file per entry, frontmatter above the body, and nothing else, so listing and search read every entry and the audit annotation says how many were scanned. That is a deliberate trade. An index would buy a cheaper listing at the price of a second copy of the truth, one that could disagree with the file it described and that nobody could open in an editor. At owner scale the scan is fine. If it ever had to serve tens of thousands of entries the fix is a derived cache that can be rebuilt from the files, never a second source of truth. ```console $ lanes link memory search "postgres" --profile personal --workspace local $ lanes link memory get deploy-runbook --profile personal --workspace local $ lanes link memory write deploy-runbook --profile personal --workspace local $ lanes link memory forget deploy-runbook --profile personal --workspace local ``` ## The files are yours to edit Frontmatter is optional on read. A plain Markdown file dropped into the directory is an entry titled after its id. The directory is one you are invited to open, and a parser that refused your file would make that invitation false. ## Making memory read-only This is the one risk in the built-ins worth an actual decision. An instruction injected into memory once is re-served to every later session, including to a different agent on a different client. Nothing else in the system persists model-authored text and hands it back as context. A poisoned entry is not a bad answer once, it is a bad answer every time until you find it. A read-only memory cannot do that, and it is one line: ```console $ lanes link policy deny memory.write memory.forget --profile personal --workspace local ``` You keep writing entries from the CLI, where you can see what you are writing. Your agents keep reading them. The only thing that goes away is a model's ability to author what it will later be told is true. ## Keeping it in a repository Memory, skills and entity files can live in a private GitHub repository instead, so they have a history and follow you between machines: ```console $ lanes link knowledge use github --repo --migrate --profile personal ``` That moves those three and nothing else. Tasks and assets stay where they are, and the vault, the credential store, and the audit log are never eligible. See [Configuration](/docs/link/configuration) for the block it writes and what it costs. ## What is recorded Ids and tags are kept. Entry text is withheld, and `memory.search` records **nothing at all**, not even the query: a search term is frequently more revealing than the result, because it says what somebody was looking for. See [the audit log](/docs/link/audit). --- **Next:** [Tasks](/docs/link/tasks) for the store that does finish, or [Built-ins](/docs/link/built-ins) for how the stores divide up. Source: https://lanes.sh/docs/link/memory --- # Tasks > What is to be done, with a status so it can be closed. Six statuses, why list shows three, and how tasks differ from memory. Tasks hold what is **to be done**. Each one has a status, which is the entire difference between this store and [memory](/docs/link/memory): a task can be closed, and a fact cannot. "Remember to chase the invoice" belongs here. Written into memory it becomes a note nothing can ever close, served back to every later session as something that is true. ## What your agent can do | Read | Write | |---|---| | `tasks.list`, `tasks.get` | `tasks.add`, `tasks.update`, `tasks.remove` | Tasks are also readable as an MCP *resource* at `tasks://task/{id}`. ## The six statuses Each is a different answer to "why is this not done": | | | |---|---| | `in_progress` | started | | `open` | not started | | `blocked` | waiting on somebody else | | `muted` | you decided to stop being reminded | | `done` | finished | | `dropped` | decided against, which is not the same fact as finished | `tasks.list` shows the first three and hides the rest, because the question is almost always what is outstanding. `dropped` is kept separate from `done` on purpose: closing something because you did it and closing it because you will not are different pieces of history, and collapsing them loses the one you are more likely to want back. ## From the CLI ```console $ lanes link tasks list --profile personal --workspace local $ lanes link tasks add "chase the invoice" --due 2026-09-01 --profile personal --workspace local $ lanes link tasks update inv-chase --status blocked --profile personal --workspace local $ lanes link tasks remove inv-chase --profile personal --workspace local ``` ## What is recorded Ids, titles, tags, statuses, and due dates are kept, because they are the shape of the work rather than its content. Task notes are withheld, and `tasks.list` records nothing. See [the audit log](/docs/link/audit). --- **Next:** [Assets](/docs/link/assets), or [Memory](/docs/link/memory) for the store that does not finish. Source: https://lanes.sh/docs/link/tasks --- # Assets > A file kept under its own name, with no id, prefix, sidecar, or index. What the naming rules are, and why an asset carries no description. Assets hold **files**. Yours, kept by name, in one profile. An asset's key is its filename. There is no id, no prefix, no sidecar, and no index, so the name you store it under is the name you ask for later. ## What your agent can do | Read | Write | |---|---| | `assets.list`, `assets.get` | `assets.store`, `assets.remove` | Also readable as an MCP *resource* at `assets://file/{name}`. A text asset reads back as text; anything else is described rather than encoded, because [bytes never pass through the model](/docs/link/attachments). ```console $ lanes link assets list --profile personal --workspace local $ lanes link assets add ./invoice-2026-03.pdf --profile personal --workspace local $ lanes link assets get invoice-2026-03.pdf --profile personal --workspace local $ lanes link assets remove invoice-2026-03.pdf --profile personal --workspace local ``` ## Naming rules A name may not end `.meta` or `.tmp`, contain a path separator, or start with a dot. Everything else is yours. ## Why an asset carries no description Deliberately. "The March invoice is in assets as `invoice-2026-03.pdf`" is a [memory entry](/docs/link/memory), and prose kept in a store with no way to search it would be worse than either putting it in memory or leaving it out. So the two work together: the file lives here, and what it *is* lives in memory, where searching for it actually works. ## What is recorded The name is kept, along with the resolved receipt: size, type, digest, and origin. The bytes are withheld. See [the audit log](/docs/link/audit). --- **Next:** [Skills](/docs/link/skills), or [Attachments](/docs/link/attachments) for how a file gets into a message without being encoded into one. Source: https://lanes.sh/docs/link/assets --- # Skills > Your own procedures, surfaced as MCP prompts rather than tools, so a person selects one and a model cannot choose it for you. Skills hold **procedures** you have written down: a release checklist, the way you file an expense, how you want a changelog worded. **A skill is a prompt, not a tool.** That is the whole design. Your agent cannot read a skill's body and cannot choose one on your behalf. You invoke it, and MCP clients surface prompts as user-selected for exactly this reason. ## Why not a tool A tool is something a model picks when it judges the moment right. A procedure is something a person picks because they have decided this is the job. If an agent could read every skill, it could shop the catalogue for its own instructions, which is a different system from the one you wrote a checklist for. So reading a skill's body sits in the `author` bundle, not the read one, and a profile granted invoke-only is offered no management tool at all. ## What your agent can do | Invoke | Author | |---|---| | any skill, as a prompt | `skills.manage.list`, `skills.manage.get`, `skills.manage.write`, `skills.manage.remove` | To keep an agent able to run your procedures but never to write one: ```console $ lanes link policy deny 'skills.manage.*' --profile personal --workspace local ``` ## From the CLI ```console $ lanes link skills list --profile personal --workspace local $ lanes link skills show release-checklist --profile personal --workspace local $ lanes link skills add ./release-checklist.md --profile personal --workspace local $ lanes link skills remove release-checklist --profile personal --workspace local ``` A skill is a Markdown file with YAML frontmatter, and `{{argument}}` in the body is substituted when it is invoked. ## Reconnect after adding one A skill moves no counter. Adding a tool changes the advertised tool count, and that count is how you normally tell an agent is stale; a prompt changes nothing anyone can see. So after `lanes link skills add`, reconnect your agent on the same reasoning you would for a new connection, without waiting for a number to change. ## They belong to one profile A skill you wrote under `work` is not available under `personal`. Nothing crosses. --- **Next:** [Identity](/docs/link/identity), or [Built-ins](/docs/link/built-ins) for how the seven divide up. Source: https://lanes.sh/docs/link/skills --- # Vault > Credentials your agents can spend without reading. One capability per item, no listing, and why a value written now is not readable until the endpoint restarts. The vault holds **credentials**. An agent uses one to do the thing that needs it, and does not quote it back, summarise it, or write it anywhere. ## One capability per item There is no `vault.get` that takes an item name. There is `vault.get.github_token`, and `vault.get.stripe_key`, and one capability for every other item you have stored. That is what makes the policy-filtered tool list *the* listing, and the only listing that cannot over-report. An agent granted `vault.get.github_token` cannot discover that `vault.get.bank_password` exists, because a capability it has no grant for is never advertised to it. **There is no `vault.list`.** Adding one would hand back exactly the thing the design just removed. ## Tools only, never resources Resources are listable and cacheable. Both are wrong for a secret, so the vault publishes no resources at all. ## A write cannot hand itself a read Capabilities are fixed for the life of a process. An item written by `vault.put` is therefore **not readable until the endpoint restarts**, because the `vault.get.` capability for it did not exist when the tool list was built. This surprises people once. It is the same property that makes the tool list trustworthy. ## What your agent can do | Read | Write | |---|---| | `vault.get.`, one per stored item | `vault.put`, `vault.remove` | ## From the CLI ```console $ lanes link vault list --profile personal --workspace local $ lanes link vault set stripe-key --profile personal --workspace local $ lanes link vault get stripe-key --profile personal --workspace local $ lanes link vault remove stripe-key --profile personal --workspace local $ lanes link vault key generate --profile personal ``` Values go in on stdin, never as a flag. An argument lands in your shell history, in `ps` output, and in any transcript. ## Where it lives One AES-256-GCM encrypted document under its own key, and it never uses the system credential store. It is never kept in a repository, and `lanes link knowledge use github` will not move it. That separation is deliberate: the vault holds secrets you want an *agent* to spend, and the credential store holds secrets that authenticate *connections*. The [security model](/docs/link/security) has the two kinds and why they stay apart. ## What is recorded The item id is kept verbatim. The value is withheld entirely, recorded as `` rather than ``, because a secret's length is a real disclosure. See [the audit log](/docs/link/audit). --- **Next:** [the audit log](/docs/link/audit), or the [security model](/docs/link/security) for the guarantee table. Source: https://lanes.sh/docs/link/vault --- # Identity > Who you are, so an agent writes as you rather than as itself. Read-only by construction, and the one built-in with no write path at all. Identity holds **who you are**: the names, addresses, and handles an agent needs in order to act as you rather than as itself. [Entities](/docs/link/entities) is the same idea pointed outward, for everybody else. Without it, an agent drafting a reply has to guess at your name, or ask, or sign off as an assistant. With it, "send that from me" resolves. ## Read-only by construction `identity.list` is the whole surface. There is no `identity.add` capability, no `identity.remove`, and no write bundle to grant. An agent that could rewrite who you are could send mail as somebody else, and no amount of policy would make that safe to offer. So it is not offered: identity is edited by you, in a terminal, and read by everything else. ## What your agent can do | Read | Write | |---|---| | `identity.list` | nothing | ## Declaring one `kind` is yours to choose. `name`, `email`, and `github` are conventions rather than a fixed set, and the order you add them in is the ranking. ```console $ lanes link identity add name "A. Lovelace" --note "for anything published" \ --profile personal --workspace local $ lanes link identity add email ada@example.com --profile personal --workspace local $ lanes link identity list --profile personal $ lanes link identity remove name Ada --profile personal --workspace local ``` `--note` says when an entry applies, and is read by whatever has to pick between several of a kind. Two email addresses with notes are more useful than two without. The first `add` also writes the connection row and the `identity.*` grant, so there is no separate step to switch it on. `remove` drops one entry and leaves both in place. ## Where it lives An `identity:` block in the profile file, not a store of its own. That is also why it is read-only where [entities](/docs/link/entities) is not: identity is configuration, and configuration is edited in a terminal. See [Configuration](/docs/link/configuration) for its shape, and [Deploying to Cloud Run](/docs/link/deployment-cloudrun) for using an identity provider you already run. --- **Next:** [Entities](/docs/link/entities) for everybody else, or [Built-ins](/docs/link/built-ins) for how the stores divide up. Source: https://lanes.sh/docs/link/identity --- # Entities > Who everyone else is: the people, companies and projects you deal with, and how to reach each of them. Looked up rather than remembered, and never guessed. Entities holds **who everyone else is**: the people, companies, projects and accounts you deal with, with the addresses and handles that reach each of them. [Identity](/docs/link/identity) says who *you* are so an agent can write as you. This is the same idea pointed outward. Without it, "email Jan about the invoice" makes an agent reach for an address it saw in a thread, and that guess does not look like a guess: an address that appeared in a mailbox your endpoint serves looks exactly like an address you use for that person. ## What your agent can do | Read | Write | |---|---| | `entities.find`, `entities.get` | `entities.write`, `entities.link`, `entities.forget` | Entities are also readable as an MCP *resource* at a stable address, `entities://entity/{id}`, for clients that read those. ## It never picks for you This is the part worth understanding, because it is the whole point. `entities.find` returns **every** match. One match is an answer. More than one is a question, and the answer shows what separates the candidates so it can usually be settled from context instead of asked: ``` 2 entities match "Jan" on `entities.main`. jan-de-vries Jan de Vries alias "Jan" · email jdv@meridian.test · works_at → meridian jan-bakker Jan Bakker alias "Jan" · email jan@acme.test · works_at → acme-bv If the context does not make it clear which is meant, ask before acting. Nothing here chooses between them, and the order is not a ranking. ``` Two matches is not an error. An assistant handed two people called Jan asks which one you meant, it does not fail, and a lookup that returned an error there would teach a client to route around it. What the tool will not do is choose. Candidates are ordered so the list reads well, but the order is never a ranking, and there is deliberately no tiebreak inside a rank: nothing quietly promotes one of two exact matches. Only the fields that actually differ are shown, because two rows of identical detail bury the one column that would have decided it. Nothing matching is not an error either. It means you have not written that person down, so the answer says to ask rather than to use an address from somewhere else. ## Looking one up Every criterion you give is combined, and a bare `lanes link entities` lists everything. ```console $ lanes link entities find Jan --profile personal --workspace local $ lanes link entities find --type person --tag client --profile personal --workspace local $ lanes link entities find --related works_at=acme-bv --profile personal --workspace local $ lanes link entities find --attr github --profile personal --workspace local ``` `--attr github` means "has a GitHub handle at all". `--attr github=janb` means that one. Both are repeatable, and so is `--related`. ## Declaring one A flag you leave out keeps what is already stored, so this is also how you correct a single field without restating the rest. Notes come from stdin and are optional. ```console $ lanes link entities write "Acme B.V." --name acme-bv --type company \ --attr domain=acme.test --profile personal --workspace local $ printf 'Prefers email over calls.' | lanes link entities write "Jan Bakker" \ --type person --alias Jan --attr email=jan@acme.test --attr github=janb \ --related works_at=acme-bv --profile personal --workspace local ``` `type` is yours to choose. `person`, `company` and `project` are conventions rather than a fixed set, so `vessel` or `client` need no release. Attributes are a **list, not a map**, and the order is the ranking: the first of a kind is the default, and a `note` says when to prefer another. That is the same rule identity follows, and it is why two email addresses are expressible at all. The id is derived from the name the way a memory id is derived from a title, so `Acme B.V.` becomes `acme-b-v`. Pass `--name acme-bv` when that matters. ## Relationships An edge is written on the entity that declares it, once, and the reverse direction is derived: ```console $ lanes link entities link jan-bakker knows=marta-silva --profile personal --workspace local $ lanes link entities get acme-bv --profile personal --workspace local ``` Acme's own file never mentions Jan, and `get acme-bv` still shows `← works_at Jan Bakker`. Writing both sides would be two files for one fact, and a run interrupted between them would leave half an edge that nothing detects. The other end does not have to exist yet. An edge to something undeclared is kept and shown as such rather than hidden, and you are told when you create one. `forget` does not clean up edges pointing at what it removes, because a delete that rewrote five other files could not be reviewed as one change. It names them before asking. ## The files are yours to edit One Markdown file per entity, frontmatter above your notes, exactly as memory works. A file you drop in by hand is an entity named after its filename, and an edit you make in an editor is what the next lookup returns. ```yaml type: person name: Jan Bakker aliases: [Jan, JB] attributes: - { kind: email, value: jan@acme.test, note: work } - { kind: email, value: j.bakker@example.net, note: personal } relations: - { predicate: works_at, entity: acme-bv, note: since 2023 } ``` There is one thing beside them that memory does not have: a derived `_index.json`, so a lookup opens no entity files at all. It is a cache, not a second copy of the truth. It carries a fingerprint of the files it was built from, so editing one by hand invalidates it and the next read rebuilds. A corrupt, truncated or stale index is never an error, only a rebuild. ```console $ lanes link entities reindex --profile personal --workspace local ``` You need that only when a write is not coming: a bulk edit made in an editor, or a pull from a repository. It says whether it had to do anything, and why. At a thousand entities on a bucket a lookup is one listing and one read, whatever the count. At ten thousand this is the wrong shape and the index stops paying for itself. ## Making it read-only Three rules, not one, because three capabilities change something: ```console $ lanes link policy deny entities.write entities.link entities.forget \ --profile personal --workspace local ``` Worth considering for the same reason as [memory](/docs/link/memory), and a little more sharply. An entry injected into memory is re-served as something true. An address injected here is *used*: it is what the next message is sent to. ## Keeping it in a repository Entity files can live in a private GitHub repository along with memory and skills, so they have a history and follow you between machines: ```console $ lanes link knowledge use github --repo --migrate --profile personal ``` The derived index travels with them, because it is derived from what travels. That means it appears in a commit that otherwise changed one attribute, which is a real cost of keeping it beside the documents. Tasks and assets stay where they are, and the vault, the credential store and the audit log are never eligible. ## What is recorded Ids are kept. Values are not, and neither is your lookup: a query here is frequently somebody else's address, typed by whoever was asking. What the log keeps instead is which entities came back, which is more useful to read later and is stable across spellings. A write records the *kinds* that changed, `email` and `github`, without the addresses themselves. See [the audit log](/docs/link/audit). --- **Next:** [the audit log](/docs/link/audit), or [Built-ins](/docs/link/built-ins) for how the stores divide up. Source: https://lanes.sh/docs/link/entities --- # Audit log > Every capability call, allowed or refused, in a hash-chained append-only record. What an entry keeps, what it withholds, and how to verify nothing was altered. Every capability invocation is recorded. Allowed, denied, rate-limited, or failed: one event each, written by the dispatcher as the call goes through it. The log is the answer to "what did my agents actually do", and it is the reason a single endpoint in front of your accounts is worth more than the same accounts wired up separately. Nothing else in the system knows the whole picture. ## Reading it ```console $ lanes link audit tail --limit 25 --profile personal --workspace local $ lanes link audit tail --denied-only --format md --profile personal --workspace local ``` `--denied-only` is usually the interesting half. A refusal is the permission system working, and a run of them tells you either that an agent is reaching for something you meant to withhold, or that you narrowed something further than you meant to. ## Verifying it Each record carries the hash of the one before it, so the log is append-only in a way you can check rather than trust: ```console $ lanes link audit verify --profile personal --workspace local ``` An edit shows as a hash mismatch, a removal as a sequence gap, and a truncated run as a count that disagrees with its marker. It exits non-zero on a break. ## What an entry keeps The rule across every provider is the same: **keep the shape, withhold the words.** Each provider declares which of its arguments are identifiers and which are content, because an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. A message id is kept. The message is not. Three consequences worth stating outright: **A search query is withheld everywhere.** `gmail.users.messages.list` records nothing at all, not even `q`. A search term is frequently more revealing than its result, because it says what somebody was looking for. The same reasoning covers `memory.search`, `tasks.list`, and `icloud_contacts.search_contacts`. **Withholding is the default, not the exception.** A provider that declares no redaction rules gets every argument value withheld. That is why a vendor-run MCP server gives you a [thinner record](/docs/link/providers) than Gmail does: nobody authored a rule list for capabilities nobody here wrote, so nothing is assumed safe to keep. **Length can be a disclosure.** A vault value is recorded as `` rather than ``, because the length of a secret is itself worth something. Each [provider page](/docs/link/providers) says what its own calls keep and withhold. ## The six results | | | |---|---| | `allowed` | it ran | | `denied_by_policy` | a rule you wrote refused it | | `denied_default` | nothing granted it; absent policy grants nothing | | `denied_unauthenticated` | no valid caller | | `denied_rate_limited` | too many, too fast | | `denied_connection_unauthorized` | the capability was allowed, that connection was not | ## What is not audited Two gaps, documented rather than glossed over. Both are also rows in the [security model](/docs/link/security). **Failed authentication is logged, not audited.** A refusal record needs a principal, and failing authentication is precisely not having one. A rejected credential goes to the endpoint's operational log instead, naming the reason and never the value presented. **Arguments that fail schema validation.** A call to an advertised tool whose arguments are rejected is stopped by the protocol layer before dispatch runs. Nothing is invoked, the caller gets a clear error, and no row is written. A third, narrower one: a client speaking the 2025-era protocol can enumerate which capabilities exist without leaving a refusal trace. It is not reachable unauthenticated and nothing is invoked either way. [Security model](/docs/link/security) has the full accounting. ## Where it lives With the workspace, alongside the other stores. It is never moved into a repository by `lanes link knowledge use github`, and never will be: a log you can rewrite by force-pushing is not a log. --- **Next:** the [security model](/docs/link/security) for the guarantee table, or [Capabilities](/docs/link/capabilities) for why each capability takes the shape it does. Source: https://lanes.sh/docs/link/audit --- # Connections > What a connection is, the methods behind one, and what happens the moment you make it. The entry point to the provider catalogue and to narrowing what an agent may do. An account you connect becomes one connection: its own credential, its own permissions, its own label. Connect it once, and every agent you have registered can reach it. ```console $ lanes link connect gmail --profile personal --workspace local ``` Run the same command again for a second mailbox. Each account is its own connection, and they do not share anything but the provider they came from. ```console $ lanes link status --profile personal --workspace local ``` `status` is the answer to "what do my agents actually have". It lists every connection and what each one makes reachable. ## What you can connect **[Every provider](/docs/link/providers)** has the full catalogue, 105 of them, each one command. If what you want is not on it, **[add your own](/docs/link/add-your-own)**: any MCP server, REST API, IMAP or CalDAV host, or local folder becomes a provider from a YAML manifest, validated by exactly the same schema as the built-in ones. Some of what your agents reach is not an account at all. Memory, tasks, assets, skills, identity, and the vault are [built in](/docs/link/built-ins), and a new profile arrives with them already granted. ## The methods behind a connection Every connection authenticates one of a handful of ways. Which one a provider uses is the only thing that changes what connecting asks of you: | | What it means for you | |---|---| | **Dynamic registration** | A browser opens, you approve, done. The vendor registers Lanes Link as you run the command, so there is no console to visit. Most providers work this way | | **A client we operate** | A browser opens, and the OAuth client belongs to Lanes rather than to you, so no client secret of theirs sits on your machine. `--own-client` opts out | | **An application of your own** | The vendor offers no dynamic registration, so you register once in their console. `setup plan` prints the exact steps | | **A token you paste** | No browser, which also means it works over SSH and in a container | | **An app password** | Mail, calendars, and contacts over IMAP, CalDAV, and CardDAV. Often one password covers all three | | **Nothing at all** | A folder already on your machine | | **A signed handshake** | A keypair rather than an OAuth flow. bunq is the one that works this way | The catalogue is [grouped by these](/docs/link/providers), so you can see what a connection will cost you before you start. ## See what it needs before you start ```console $ lanes link setup plan --profile personal --workspace local # every provider, connected or not $ lanes link setup plan icloud_mail --profile personal --workspace local # the steps, the values, the command ``` An agent connected to your endpoint can read the same thing, so it can hand you the exact command rather than guessing at one. That surface is read-only: connecting, credentials, and permissions stay in this CLI. ## It works straight away As soon as `connect` finishes. It saves the connection, copies the config to wherever that workspace's endpoint reads it, and asks the endpoint to re-read it. A running `lanes link start` picks it up without a restart, and a deployed endpoint picks it up without a new revision. The last line tells you which happened, and how many tools your agents can now see: ```console Next: Serving it now. The endpoint has re-read its config. 42 tools are advertised now. A client connected before this keeps the list it already fetched, so reconnect it to pick them up. ``` If no endpoint was running, it says so instead. The connection is saved either way, and served the next time that endpoint starts. ## Your agent needs reconnecting, though An agent reads the tool list when it connects and then keeps it. Lanes Link holds no session, so there is no channel to tell it otherwise. An agent you registered before this connection still shows the tools it saw back then. Remove it and add it again. For a hosted connector that is Disconnect and re-paste the URL; for a local one, whatever your agent's equivalent is. To see what an agent would be handed right now: ```console $ lanes link tools --profile personal --workspace local ``` If that count matches your agent, its tools are current. If not, reconnect it. [Skills](/docs/link/skills) are a special case: a skill is a prompt rather than a tool, so adding one moves no counter and nothing announces it. After `lanes link skills add`, reconnect on the same reasoning without waiting for a number to change. ## Narrowing what an agent may do Connecting grants a read bundle. Tightening it is instant and happens on your machine: ```console $ lanes link policy deny gmail.send --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. Widening what the *vendor* permits is a different matter and needs browser re-consent. [Scopes and permissions](/docs/link/scopes) has both directions, what a bundle is, and why a rule cannot name a single connection. ## Connecting without a terminal to answer Where the credential is a key or a password rather than a browser sign-in, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set github/octocat --profile personal $ lanes link connect github --id octocat --non-interactive --json --profile personal ``` `--non-interactive` never prompts. It resolves every value from the credential store, or refuses and prints exactly what is missing and the command that stores it. Anything that needs a browser is refused outright rather than opening a window nobody is watching. Credentials go in on stdin, never as a flag. An argument lands in your shell history, in `ps` output, and in any transcript. --- **Next:** [every provider](/docs/link/providers), then [Add it to your agent](/docs/link/clients) and [Deploy to your own cloud](/docs/link/deploy) when you want it reachable from a phone. Source: https://lanes.sh/docs/link/connect --- # Scopes and permissions > The two different things called scope, how to narrow one instantly and widen the other, and what can and cannot be set per connection. Two different things get called "scope", they are enforced in different places, and confusing them is how people end up granting more than they meant to. | | Where it lives | Changing it | |---|---|---| | **What the vendor granted** | the vendor's consent screen | widening needs a browser, narrowing needs a reconnect | | **What your agent may spend** | your policy file, on your machine | instant, both directions | The first is a ceiling. The second is where you actually work. Which scopes a given provider asks for, and which it deliberately does not, is on that provider's own page. [Every provider](/docs/link/providers) has one. ## What your agent may spend Connecting grants a read bundle. Everything after that is policy, and policy is local: ```console $ lanes link policy list --profile personal $ lanes link policy deny gmail.send --profile personal --workspace local $ lanes link policy allow 'drive.*' --profile personal --workspace local ``` Rules are glob patterns over capability names. Two properties matter: **A deny always beats an allow**, whatever the order in the file. There is no precedence puzzle to reason about, and no way to accidentally re-grant something by adding a rule below. **An absent policy grants nothing.** The default is deny, so a capability nobody wrote a rule for is not reachable. `connect` writes one rule per provider, which is why a fresh connection works at all. A rule can also expire, with `expires_at`, for access you want to hand out for an afternoon. ## Bundles A bundle is a named group of capabilities: `read` and `write` are the two you will see. For providers built on an HTTP specification they are derived from the method, so `GET` and `HEAD` land in `read` and the mutating verbs land in `write`, with nobody curating a list that could quietly drift from what the API does. Granting one is how you widen without naming twenty capabilities: ```console $ lanes link connect gmail --add write --profile personal --workspace local ``` ## Widening what the vendor granted This is the one that cannot happen locally. If the vendor never granted it, no policy rule conjures it, and the endpoint has nothing to spend. ```console $ lanes link connect --add --profile personal --workspace local ``` That reopens the browser, because a wider grant is the vendor's decision and yours, not ours. Some scopes are marked **broad**, and `connect` stops and asks for an explicit yes before requesting one rather than sliding it in with the rest. ## Narrowing what the vendor granted Reduce the bundles and reconnect. A vendor with a revocation endpoint has the old grant revoked as part of it; where there is none, the grant stays live at the vendor until you remove the app in their account settings, and the page for that provider says so. Denying the capability locally is the faster half of this and takes effect at once. Do both when the point is to remove access rather than to stop using it. ## Scopes are not set per connection Worth stating plainly, because the shape of the CLI suggests otherwise. **Policy rules name capabilities, never connections.** Every account of a provider inside one profile is governed identically. If you have two Gmail connections in `personal`, denying `gmail.send` denies it for both. There is no rule that reaches one and not the other. That is deliberate rather than missing. A rule that named a connection would be a rule whose meaning changed when you relabelled one, and policy that silently re-points is worse than policy that cannot express something. **Granularity comes from profiles.** A profile is the unit of separation, so the way to give one agent a narrower view is to give it a narrower profile: ```console $ lanes link profile add reading --profile-default false $ lanes link connect gmail --profile reading --workspace local $ lanes link policy deny 'gmail.send' 'gmail.users.messages.trash' --profile reading --workspace local ``` Register that profile with the agent you want held back, and the wider one with the agent you trust. Nothing in `reading` can see what `personal` holds, which is the same isolation the [built-ins](/docs/link/built-ins) get. ## Seeing what an agent actually has ```console $ lanes link status --profile personal --workspace local $ lanes link tools --profile personal --workspace local ``` `status` lists every connection and what it makes reachable. `tools` is the exact list an agent would be handed right now. If the count your client shows disagrees with `tools`, your client is stale and wants reconnecting, not repolicying. ## Everything is recorded either way A refused call is an audit entry, not a silent no-op. `lanes link audit tail --denied-only` is how you find out that a policy is narrower than the work needs, or that an agent is reaching somewhere you did not expect. See [the audit log](/docs/link/audit). --- **Next:** [every provider](/docs/link/providers) for the scopes each one asks for, or [Capabilities](/docs/link/capabilities) for why a capability is a tool, a resource, or a prompt. Source: https://lanes.sh/docs/link/scopes --- # Add your own > Any MCP server, REST API, IMAP mailbox, CalDAV or CardDAV server, or folder on your machine If a service is not on the list, you can add it yourself. One command: ```console $ lanes link connect custom thing --connector http --auth api-key --auth-header X-Api-Key \ --base-url https://api.example.com/v1 --openapi https://api.example.com/openapi.json \ --profile personal --workspace local ``` Leave out a value it needs and it asks. ## The two choices A provider is exactly two decisions: how the service is reached, and how you prove who you are. | `--connector` | For | |---|---| | `mcp` | Any MCP server | | `http` | Any REST API with an OpenAPI description | | `imap` | Any mailbox | | `dav` | Any CalDAV or CardDAV server | | `fs` | Any folder on this machine | | `--auth` | For | |---|---| | `none` | Anything public | | `bearer` | A token sent as `Authorization: Bearer` | | `api-key` | A key in a header you name with `--auth-header` | | `header` | Any other fixed header | | `basic` | A username and password | | `oauth` | A browser sign-in, where the vendor supports registration | | `strategy` | A handshake that needs code, like bunq's | Both lists are closed. Everything else about a provider is data hung off the pair, which is why adding one takes no code. ## What it writes A fifteen-line YAML manifest in `~/.lanes-link/data//providers.d/`. That is the same kind of declaration every built-in provider is, and it is yours to edit from there: ```yaml id: thing name: Thing connector: kind: http base_url: https://api.example.com/v1 openapi: https://api.example.com/openapi.json auth: kind: api-key header: X-Api-Key ``` Capabilities are discovered rather than written: from the vendor's own MCP server, or from the OpenAPI document. There is nothing per-endpoint to declare. Because it belongs to the profile, a manifest in one profile is invisible to another, and a deployed endpoint serves your custom providers the same way it serves the built-in ones. ## Where to go next - [Creating a provider](/docs/link/creating-a-provider) writes one by hand, including the cases that need more than the two lists above. - [Connectivity coverage](/docs/link/connectivity-coverage) is the honest account of which combinations work, which are closed on purpose, and what none of them reaches yet. --- **Next:** [Add it to your agent](/docs/link/clients). Source: https://lanes.sh/docs/link/add-your-own --- # Every provider > The full Lanes Link catalogue: 105 providers your agents can reach, grouped by what connecting one actually asks of you. 105 providers, each one command. This page groups them by what connecting actually asks of you, because that is the only difference between them that costs you anything. If what you want is not here, [add your own](/docs/link/add-your-own): any MCP server, REST API, IMAP or CalDAV host, or local folder, in one command. ## Nothing to set up The vendor registers Lanes Link at the moment you run `connect`. A browser opens, you approve, and it is live. There is no console to visit and no client to create. | | Connect with | What it gives your agent | |---|---|---| | [Airtable](/docs/link/airtable) † | `lanes link connect airtable` | Bases, tables, records, and schema | | [Algolia](/docs/link/algolia) † | `lanes link connect algolia` | Search indices, records, queries, and synonyms | | [Amplitude](/docs/link/amplitude) † | `lanes link connect amplitude` | Events, charts, cohorts, and user activity | | [Apify](/docs/link/apify) † | `lanes link connect apify` | Actors, runs, datasets, and scraped results | | [Asana](/docs/link/asana) † | `lanes link connect asana` | Tasks, projects, portfolios, and workspaces | | [Atlassian](/docs/link/atlassian) † | `lanes link connect atlassian` | Issues, pages, and components | | [Attio](/docs/link/attio) † | `lanes link connect attio` | Records, lists, notes, and tasks in the CRM | | [Better Stack](/docs/link/betterstack) † | `lanes link connect betterstack` | Incidents, monitors, heartbeats, and logs | | [Bright Data](/docs/link/brightdata) † | `lanes link connect brightdata` | Web scraping, search results, and datasets | | [Buildkite](/docs/link/buildkite) † | `lanes link connect buildkite` | Pipelines, builds, jobs, and artifacts | | [Calendly](/docs/link/calendly) † | `lanes link connect calendly` | Scheduled events, invitees, and availability | | [Canva](/docs/link/canva) † | `lanes link connect canva` | Designs, folders, brand templates, and exports | | [CircleCI](/docs/link/circleci) † | `lanes link connect circleci` | Pipelines, workflows, jobs, and test results | | [ClickUp](/docs/link/clickup) † | `lanes link connect clickup` | Tasks, lists, spaces, docs, and time entries | | [Close](/docs/link/close) † | `lanes link connect close` | Leads, contacts, opportunities, and activities | | [Cloudflare Bindings](/docs/link/cloudflare-bindings) † | `lanes link connect cloudflare_bindings` | Workers KV, R2, D1, and Durable Objects | | [Cloudflare Observability](/docs/link/cloudflare-observability) † | `lanes link connect cloudflare_observability` | Workers logs, analytics, and traces | | [Contentful](/docs/link/contentful) † | `lanes link connect contentful` | Entries, assets, content types, and spaces | | [Datadog](/docs/link/datadog) † | `lanes link connect datadog` | Metrics, logs, monitors, incidents, and dashboards | | [Dropbox](/docs/link/dropbox) † | `lanes link connect dropbox` | Files, folders, shared links, and file requests | | [Expensify](/docs/link/expensify) † | `lanes link connect expensify` | Expenses, reports, and receipts | | [Figma](/docs/link/figma) † | `lanes link connect figma` | Files, designs, components, and Dev Mode context | | [Fireflies](/docs/link/fireflies) † | `lanes link connect fireflies` | Meeting transcripts, summaries, and action items | | [Flagsmith](/docs/link/flagsmith) † | `lanes link connect flagsmith` | Feature flags, segments, and environments | | [Gamma](/docs/link/gamma) † | `lanes link connect gamma` | Presentations and documents, generated and read back | | [Grafana](/docs/link/grafana) † | `lanes link connect grafana` | Dashboards, datasources, queries, and alert rules | | [Heroku](/docs/link/heroku) † | `lanes link connect heroku` | Apps, dynos, add-ons, releases, and logs | | [Hygraph](/docs/link/hygraph) † | `lanes link connect hygraph` | Content entries, models, and schema | | [Insightly](/docs/link/insightly) † | `lanes link connect insightly` | Contacts, organisations, opportunities, and projects | | [Jam](/docs/link/jam) † | `lanes link connect jam` | Bug reports, with console logs, network calls, and repro steps | | [Klaviyo](/docs/link/klaviyo) † | `lanes link connect klaviyo` | Profiles, lists, segments, campaigns, and flows | | [Linear](/docs/link/linear) | `lanes link connect linear` | Linear's own MCP server | | [Mercury](/docs/link/mercury) † | `lanes link connect mercury` | Accounts, balances, transactions, and cards | | [Miro](/docs/link/miro) † | `lanes link connect miro` | Boards, frames, sticky notes, and shapes | | [Mixpanel](/docs/link/mixpanel) † | `lanes link connect mixpanel` | Events, funnels, retention, and cohorts | | [monday.com](/docs/link/monday) † | `lanes link connect monday` | Boards, items, groups, columns, and updates | | [Mux](/docs/link/mux) † | `lanes link connect mux` | Video assets, live streams, and playback analytics | | [Navan](/docs/link/navan) † | `lanes link connect navan` | Trips, bookings, and travel expenses | | [Neon](/docs/link/neon) † | `lanes link connect neon` | Postgres projects, branches, and SQL | | [Netlify](/docs/link/netlify) † | `lanes link connect netlify` | Sites, deploys, functions, and environment variables | | [Notion](/docs/link/notion) | `lanes link connect notion` | Notion's own MCP server | | [Paddle](/docs/link/paddle) † | `lanes link connect paddle` | Products, prices, subscriptions, and transactions | | [PayPal](/docs/link/paypal) † | `lanes link connect paypal` | Invoices, orders, payments, and disputes | | [PostHog](/docs/link/posthog) † | `lanes link connect posthog` | Events, insights, feature flags, and session replays | | [Prisma](/docs/link/prisma) † | `lanes link connect prisma` | Postgres databases, schema, and migrations | | [Ramp](/docs/link/ramp) † | `lanes link connect ramp` | Cards, transactions, reimbursements, and spend limits | | [Recurly](/docs/link/recurly) † | `lanes link connect recurly` | Subscriptions, invoices, and accounts | | [Remote](/docs/link/remote) † | `lanes link connect remote` | Employees, contracts, payroll, and time off | | [Replicate](/docs/link/replicate) † | `lanes link connect replicate` | Models, predictions, and deployments | | [Resend](/docs/link/resend) † | `lanes link connect resend` | Transactional email, domains, and delivery events | | [Riverside](/docs/link/riverside) † | `lanes link connect riverside` | Recordings, transcripts, and clips | | [Rootly](/docs/link/rootly) † | `lanes link connect rootly` | Incidents, alerts, retrospectives, and on-call schedules | | [RudderStack](/docs/link/rudderstack) † | `lanes link connect rudderstack` | Sources, destinations, and event streams | | [Salesloft](/docs/link/salesloft) † | `lanes link connect salesloft` | Cadences, people, and sales activity | | [Sanity](/docs/link/sanity) † | `lanes link connect sanity` | Documents, datasets, schema, and content releases | | [Sentry](/docs/link/sentry) † | `lanes link connect sentry` | Issues, events, stack traces, and releases | | [Shortcut](/docs/link/shortcut) † | `lanes link connect shortcut` | Stories, epics, iterations, and workflows | | [Square](/docs/link/square) † | `lanes link connect square` | Payments, orders, catalog, inventory, and customers | | [Storyblok](/docs/link/storyblok) † | `lanes link connect storyblok` | Stories, components, assets, and spaces | | [Stripe](/docs/link/stripe) † | `lanes link connect stripe` | Payments, customers, invoices, and subscriptions | | [Supabase](/docs/link/supabase) † | `lanes link connect supabase` | Projects, database schema, SQL, and edge functions | | [Tavily](/docs/link/tavily) † | `lanes link connect tavily` | Web search and page content extraction | | [Todoist](/docs/link/todoist) † | `lanes link connect todoist` | Tasks, projects, sections, labels, and filters | | [Vercel](/docs/link/vercel) † | `lanes link connect vercel` | Projects, deployments, build logs, and domains | | [Vimeo](/docs/link/vimeo) † | `lanes link connect vimeo` | Videos, folders, showcases, and analytics | | [Webflow](/docs/link/webflow) † | `lanes link connect webflow` | Sites, pages, CMS collections, and items | | [Whimsical](/docs/link/whimsical) † | `lanes link connect whimsical` | Boards, flowcharts, wireframes, and mind maps | | [Wix](/docs/link/wix) † | `lanes link connect wix` | Sites, stores, bookings, and CMS data | | [Workable](/docs/link/workable) † | `lanes link connect workable` | Jobs, candidates, and interviews | | [Zapier](/docs/link/zapier) † | `lanes link connect zapier` | Zaps, and the actions they reach across thousands of apps | ## Browser sign-in, through a client we operate A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. `--own-client` opts out and uses one you registered. | | Connect with | What it gives your agent | |---|---|---| | [Gmail](/docs/link/gmail) | `lanes link connect gmail` | Read, search, send, draft, and organise mail | | [Google Calendar](/docs/link/google-calendar) | `lanes link connect calendar` | Read and write events, and answer when you are free | | [Google Contacts](/docs/link/google-contacts) | `lanes link connect contacts` | Look up an address, so "email Bob" resolves. Read-only | | [Google Docs](/docs/link/google-docs) | `lanes link connect docs` | Read a document and edit its content | | [Google Drive](/docs/link/google-drive) | `lanes link connect drive` | Search, read, export, and share files | | [Google Sheets](/docs/link/google-sheets) | `lanes link connect sheets` | Read and edit cells, ranges, and tabs | | [Google Tasks](/docs/link/google-tasks) | `lanes link connect google_tasks` | Create, edit, complete, and reorder tasks | | [Slack](/docs/link/slack) | `lanes link connect slack` | Search, read, and send messages, threads, files, and canvases | ## Needs an application of your own These offer no dynamic registration, so you register an application once in the vendor's console. `lanes link setup plan ` prints the steps, the values, and the command. | | Connect with | What it gives your agent | |---|---|---| | [Box](/docs/link/box) † | `lanes link connect box` | Files and folders, needs an OAuth app of your own | | [Gmail (Google MCP)](/docs/link/gmail-mcp) | `lanes link connect gmail_mcp` | Google's own MCP server. Developer Preview only | | [Google Drive (Google MCP)](/docs/link/drive-mcp) | `lanes link connect drive_mcp` | Likewise; use `drive` unless you are enrolled | | [HubSpot](/docs/link/hubspot) † | `lanes link connect hubspot` | CRM records, needs an MCP auth app of your own | | [Microsoft To Do](/docs/link/microsoft-todo) † | `lanes link connect microsoft_todo` | Create, edit, complete, and organise tasks and lists | | [OneDrive](/docs/link/onedrive) † | `lanes link connect onedrive` | Browse, search, read, and organise files | | [Outlook Calendar](/docs/link/outlook-calendar) † | `lanes link connect outlook_calendar` | Read and write events, and answer when you are free | | [Outlook Contacts](/docs/link/outlook-contacts) † | `lanes link connect outlook_contacts` | Look up an address, so "email Bob" resolves. Read-only | | [Outlook Mail](/docs/link/outlook-mail) † | `lanes link connect outlook_mail` | Read, search, file, and send over Microsoft Graph | | [Reddit](/docs/link/reddit) | `lanes link connect reddit` | Read subreddits and comments, search, and post, comment, and vote as you | ## A token or key you paste No browser at all, which also means these work over SSH and inside a container. An agent with a shell can do the whole setup non-interactively. | | Connect with | What it gives your agent | |---|---|---| | [Discord](/docs/link/discord) | `lanes link connect discord` | Post announcements and read channels, as a bot application you own | | [GitHub](/docs/link/github) | `lanes link connect github` | Repositories, issues, pull requests, and workflow runs | | [Render](/docs/link/render) † | `lanes link connect render` | Services and deploys, takes an API key you paste | ## An app password Mail, calendars, and contacts over IMAP, CalDAV, and CardDAV. One app password often covers all three for the same account. | | Connect with | What it gives your agent | |---|---|---| | [Fastmail Calendar](/docs/link/fastmail-calendar) † | `lanes link connect fastmail_calendar` | Read and write events over CalDAV | | [Fastmail Contacts](/docs/link/fastmail-contacts) † | `lanes link connect fastmail_contacts` | Look up an address over CardDAV | | [Fastmail Mail](/docs/link/fastmail-mail) † | `lanes link connect fastmail_mail` | Read, search, and send over IMAP and SMTP | | [Gmail (IMAP)](/docs/link/gmail-imap) | `lanes link connect gmail_imap` | The same mailbox over IMAP and SMTP, with an app password that does not expire | | [iCloud Calendar](/docs/link/icloud-calendar) | `lanes link connect icloud_calendar` | Read and write events over CalDAV | | [iCloud Contacts](/docs/link/icloud-contacts) | `lanes link connect icloud_contacts` | Look up an address over CardDAV | | [iCloud Mail](/docs/link/icloud-mail) | `lanes link connect icloud_mail` | Read, search, and send over IMAP and SMTP | | [Mailbox (any IMAP server)](/docs/link/mailbox) † | `lanes link connect mailbox` | Read, search, and send on a host with no entry of its own | | [Nextcloud Calendar](/docs/link/nextcloud-calendar) † | `lanes link connect nextcloud_calendar` | Events over CalDAV, on your own server | | [Nextcloud Contacts](/docs/link/nextcloud-contacts) † | `lanes link connect nextcloud_contacts` | Contacts over CardDAV, on your own server | | [Yahoo Mail](/docs/link/yahoo-mail) † | `lanes link connect yahoo_mail` | Read, search, and send over IMAP and SMTP | | [Zoho Mail](/docs/link/zoho-mail) † | `lanes link connect zoho_mail` | Read, search, and send over IMAP and SMTP | ## On your own machine A folder that is already there. Nothing to authenticate to. | | Connect with | What it gives your agent | |---|---|---| | [iCloud Drive](/docs/link/icloud-drive) | `lanes link connect icloud_drive` | Your sync folder, on the Mac that syncs it | ## Its own handshake A keypair and a signed exchange rather than an OAuth flow. | | Connect with | What it gives your agent | |---|---|---| | [bunq](/docs/link/bunq) | `lanes link connect bunq` | Accounts, balances, transaction history, and payments | ## Guides that cover a vendor rather than a provider Google and iCloud each serve several providers from one account, so the console work and the app password are done once for all of them: [Google](/docs/link/google) covers Gmail, Drive, Sheets, Docs, Calendar, Tasks, and Contacts. [iCloud](/docs/link/icloud) covers Mail, Calendar, Contacts, and Drive. ## About the 84 marked † **† means untested.** The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. The mark is on the page too, and it comes off when somebody reports connecting it. ## Before you start ```console $ lanes link setup plan --profile personal --workspace local # every provider, connected or not $ lanes link setup plan icloud_mail --profile personal --workspace local # the steps, the values, the command ``` An agent connected to your endpoint can read the same thing, so it can hand you the exact command rather than guessing at one. That surface is read-only: connecting, credentials, and permissions stay in the CLI. --- **Next:** [Connections](/docs/link/connect) for what a connection is, or [Scopes and permissions](/docs/link/scopes) for narrowing one. Source: https://lanes.sh/docs/link/providers --- # Airtable > Connect Airtable to your agents with Lanes Link. Bases, tables, records, and schema. Bases, tables, records, fields, and schema, via Airtable's official MCP server. ## Connect it ```console $ lanes link connect airtable --profile personal --workspace local ``` Airtable registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Airtable's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Airtable's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'airtable.*' --connection airtable.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/airtable --- # Algolia > Connect Algolia to your agents with Lanes Link. Search indices, records, queries, and synonyms. Search indices, records, queries, and synonyms, via Algolia's official MCP server. ## Connect it ```console $ lanes link connect algolia --profile personal --workspace local ``` Algolia registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Algolia's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Algolia's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'algolia.*' --connection algolia.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/algolia --- # Amplitude > Connect Amplitude to your agents with Lanes Link. Events, charts, cohorts, and user activity. Events, charts, cohorts, and user activity, via Amplitude's official MCP server. ## Connect it ```console $ lanes link connect amplitude --profile personal --workspace local ``` Amplitude registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Amplitude's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Amplitude's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'amplitude.*' --connection amplitude.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/amplitude --- # Apify > Connect Apify to your agents with Lanes Link. Actors, runs, datasets, and scraped results. Actors, runs, datasets, and scraped results, via Apify's official MCP server. ## Connect it ```console $ lanes link connect apify --profile personal --workspace local ``` Apify registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Apify's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Apify's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'apify.*' --connection apify.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/apify --- # Asana > Connect Asana to your agents with Lanes Link. Tasks, projects, portfolios, and workspaces. Tasks, projects, portfolios, and workspaces, via Asana's official MCP server. ## Connect it ```console $ lanes link connect asana --profile personal --workspace local ``` Asana registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Asana's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Asana's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'asana.*' --connection asana.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/asana --- # Atlassian > Connect Atlassian to your agents with Lanes Link. Issues, pages, and components. Jira issues, Confluence pages, and Compass components, via Atlassian's official MCP server. ## Connect it ```console $ lanes link connect atlassian --profile personal --workspace local ``` Atlassian registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Atlassian's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Atlassian's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'atlassian.*' --connection atlassian.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/atlassian --- # Attio > Connect Attio to your agents with Lanes Link. Records, lists, notes, and tasks in the CRM. Records, lists, notes, and tasks in the CRM, via Attio's official MCP server. ## Connect it ```console $ lanes link connect attio --profile personal --workspace local ``` Attio registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Attio's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Attio's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'attio.*' --connection attio.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/attio --- # Better Stack > Connect Better Stack to your agents with Lanes Link. Incidents, monitors, heartbeats, and logs. Incidents, monitors, heartbeats, and logs, via Better Stack's official MCP server. ## Connect it ```console $ lanes link connect betterstack --profile personal --workspace local ``` Better Stack registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Better Stack's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Better Stack's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'betterstack.*' --connection betterstack.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/betterstack --- # Box > Connect Box to your agents with Lanes Link. Files and folders, needs an OAuth app of your own. Files, folders, and metadata in Box, via Box's official MCP server. ## Connect it ```console $ lanes link connect box --profile personal --workspace local ``` Box does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan box` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do The tool list comes from Box's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Box's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'box.*' --connection box.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/box --- # Bright Data > Connect Bright Data to your agents with Lanes Link. Web scraping, search results, and datasets. Web scraping, search results, and datasets, via Bright Data's official MCP server. ## Connect it ```console $ lanes link connect brightdata --profile personal --workspace local ``` Bright Data registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Bright Data's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Bright Data's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'brightdata.*' --connection brightdata.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/brightdata --- # Buildkite > Connect Buildkite to your agents with Lanes Link. Pipelines, builds, jobs, and artifacts. Pipelines, builds, jobs, and artifacts, via Buildkite's official MCP server. ## Connect it ```console $ lanes link connect buildkite --profile personal --workspace local ``` Buildkite registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Buildkite's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Buildkite's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'buildkite.*' --connection buildkite.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/buildkite --- # bunq > Accounts, balances, transaction history, and payments. Read this one first ```console $ lanes link connect bunq --profile personal --workspace local ``` You are asked for one API key, generated inside the bunq app. There is no web console, no OAuth client, and no browser consent, because bunq does not offer one for your own account. **Read the next two sections before you connect this one.** It is the only account here that can move money. ## What your agent can do Eleven operations, and three of them spend. | Capability | What it does | |---|---| | `bunq.List_all_User` | The user id every other call is addressed under | | `bunq.List_all_MonetaryAccount_for_User` | Accounts and balances | | `bunq.List_all_Payment_for_User_MonetaryAccount` | Transaction history | | `bunq.READ_Payment_for_User_MonetaryAccount` | One transaction | | `bunq.List_all_DraftPayment_for_User_MonetaryAccount` | Drafts awaiting approval | | `bunq.READ_DraftPayment_for_User_MonetaryAccount` | One draft | | `bunq.List_all_PaymentBatch_for_User_MonetaryAccount` | Batches | | **`bunq.CREATE_Payment_for_User_MonetaryAccount`** | **Pays. Immediately, and irreversibly** | | **`bunq.CREATE_PaymentBatch_for_User_MonetaryAccount`** | **Pays up to 350 recipients at once** | | `bunq.CREATE_DraftPayment_for_User_MonetaryAccount` | Prepares a payment for you to approve in the app | | `bunq.UPDATE_DraftPayment_for_User_MonetaryAccount` | Accepts or rejects a draft | A direct payment has no confirmation step anywhere. Not in the app, not by email, not here. bunq accepts the call and the money is gone. A *draft* payment is the same call with a human in the middle: it appears in the bunq app and does nothing until you approve it. Standing orders, opening and closing accounts, and ordering cards are all reachable in bunq's API and deliberately absent here, so no policy rule can reach them. ## Keeping an agent to drafts only Two bounds, and only one of them is ours. **A spending limit on the API key**, set in the bunq app. This is the one that does not depend on any software here being correct: not the policy engine, not the tool list, not this page. Set it, and set it low. **Policy.** Connecting with write grants the payment tool, because for an HTTP provider every `POST` lands in the write bundle. Deny all three: ```console $ lanes link policy deny bunq.CREATE_Payment_for_User_MonetaryAccount \ bunq.CREATE_PaymentBatch_for_User_MonetaryAccount \ bunq.UPDATE_DraftPayment_for_User_MonetaryAccount --profile personal --workspace local ``` The first two are the obvious ones. The third is what makes the other two mean anything: `UPDATE_DraftPayment` with `status: ACCEPTED` *is* how a draft becomes a payment, so an agent left holding it can create a draft and then approve its own draft, and the human checkpoint you bought with the first two lines does not exist. Denying it costs you nothing an agent should have, because accepting, rejecting, and cancelling a draft are all things to do in the bunq app, which is the entire point of a draft. That leaves reading and draft-making intact, and every payment then waits for you in the app. ## The API key 1. In the bunq app: **Profile → Security & Settings → Developers → API keys → Add API key**. 2. Name it `Lanes Link`, so you can revoke this one later without touching your others. 3. **Set a spending limit on the key.** See above. 4. **If this endpoint will ever run anywhere but this machine**, mark the key as a *wildcard* key on the same screen. bunq binds a key to the addresses it has been used from, and a cloud egress address is not stable. The wildcard setting cannot be enabled over the API, deliberately, so this step is yours and nothing can do it for you. 5. Copy the key and run the connect command above. You are then asked what to call the connection. bunq publishes no endpoint that reports whose account a key belongs to, so unlike Gmail or GitHub the label is yours to choose. Connecting does more than store a value: it generates an RSA keypair on your machine, registers the public half with bunq, and stores the key, the private half, and bunq's own public key together under `bunq/`. The registration step is what rejects a wrong key, so a bad paste fails there, with bunq's own message, before anything is written to your config. Every request afterwards is signed, and bunq's replies are checked against its key. ## Try it against the sandbox first bunq runs a public sandbox that needs no bank account, and nothing that happens there is real. A sandbox key comes from bunq's tinker flow; see [doc.bunq.com](https://doc.bunq.com/). Copy the vendored spec next to a manifest of your own in `/data//providers.d/`. A relative `openapi:` resolves against the manifest's own directory, so keeping the two together is what makes the path work wherever the workspace is: ```yaml id: bunq_sandbox name: bunq (sandbox) connector: kind: http base_url: https://public-api.sandbox.bunq.com/v1 openapi: ./bunq.v1.json auth: kind: strategy strategy: bunq ``` There is no sandbox flag, on purpose. The strategy reads its host from `base_url`, so this manifest handshakes and pays against the sandbox and the built-in `bunq` does neither. A flag beside `base_url` would be a second thing to keep true, and getting it wrong would open a session against the sandbox and spend it against production. ## When it stops working | What you see | What it is | |---|---| | `bunq refused the session`, or a 401 then success | Working as intended. A session lasts as long as your account's auto-logout setting, a week by default. The call after the failure opens a new one | | Every call refused after a deploy or an ISP change | The key is bound to addresses it has been used from. Mark it as a wildcard key in the app | | `answered 429` | bunq rate-limits, and `/session-server` hardest at one call per thirty seconds. Ordinary calls are 3 GETs and 5 POSTs per three seconds | | Anything else | Generate a new key in the app and run `lanes link connect bunq --replace` | ## What is recorded Account and payment identifiers are kept, and amounts and counterparty details are withheld. Capabilities come from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. Given that this provider can move money, `lanes link audit tail --denied-only` is worth a habit. See [the audit log](/docs/link/audit). --- **Next:** [every provider](/docs/link/providers), or [Add it to your agent](/docs/link/clients). Source: https://lanes.sh/docs/link/bunq --- # Calendly > Connect Calendly to your agents with Lanes Link. Scheduled events, invitees, and availability. Scheduled events, invitees, event types, and availability, via Calendly's official MCP server. ## Connect it ```console $ lanes link connect calendly --profile personal --workspace local ``` Calendly registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Calendly's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Calendly's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'calendly.*' --connection calendly.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/calendly --- # Canva > Connect Canva to your agents with Lanes Link. Designs, folders, brand templates, and exports. Designs, folders, brand templates, assets, and exports, via Canva's official MCP server. ## Connect it ```console $ lanes link connect canva --profile personal --workspace local ``` Canva registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Canva's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Canva's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'canva.*' --connection canva.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/canva --- # CircleCI > Connect CircleCI to your agents with Lanes Link. Pipelines, workflows, jobs, and test results. Pipelines, workflows, jobs, and test results, via CircleCI's official MCP server. ## Connect it ```console $ lanes link connect circleci --profile personal --workspace local ``` CircleCI registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from CircleCI's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are CircleCI's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'circleci.*' --connection circleci.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/circleci --- # ClickUp > Connect ClickUp to your agents with Lanes Link. Tasks, lists, spaces, docs, and time entries. Tasks, lists, spaces, docs, and time entries, via ClickUp's official MCP server. ## Connect it ```console $ lanes link connect clickup --profile personal --workspace local ``` ClickUp registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from ClickUp's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are ClickUp's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'clickup.*' --connection clickup.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/clickup --- # Close > Connect Close to your agents with Lanes Link. Leads, contacts, opportunities, and activities. Leads, contacts, opportunities, and activities in the CRM, via Close's official MCP server. ## Connect it ```console $ lanes link connect close --profile personal --workspace local ``` Close registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Close's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Close's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'close.*' --connection close.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/close --- # Cloudflare Bindings > Connect Cloudflare Bindings to your agents with Lanes Link. Workers KV, R2, D1, and Durable Objects. Workers KV, R2, D1, and Durable Objects, via Cloudflare's official MCP server. ## Connect it ```console $ lanes link connect cloudflare_bindings --profile personal --workspace local ``` Cloudflare Bindings registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Cloudflare Bindings's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Cloudflare Bindings's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'cloudflare_bindings.*' --connection cloudflare_bindings.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/cloudflare-bindings --- # Cloudflare Observability > Connect Cloudflare Observability to your agents with Lanes Link. Workers logs, analytics, and traces. Workers logs, analytics, and traces, via Cloudflare's official MCP server. ## Connect it ```console $ lanes link connect cloudflare_observability --profile personal --workspace local ``` Cloudflare Observability registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Cloudflare Observability's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Cloudflare Observability's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'cloudflare_observability.*' --connection cloudflare_observability.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/cloudflare-observability --- # Contentful > Connect Contentful to your agents with Lanes Link. Entries, assets, content types, and spaces. Entries, assets, content types, and spaces, via Contentful's official MCP server. ## Connect it ```console $ lanes link connect contentful --profile personal --workspace local ``` Contentful registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Contentful's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Contentful's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'contentful.*' --connection contentful.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/contentful --- # Datadog > Connect Datadog to your agents with Lanes Link. Metrics, logs, monitors, incidents, and dashboards. Metrics, logs, monitors, incidents, and dashboards, via Datadog's official MCP server. ## Connect it ```console $ lanes link connect datadog --profile personal --workspace local ``` Datadog registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Datadog's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Datadog's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'datadog.*' --connection datadog.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/datadog --- # Discord > Post announcements and read channels, as a bot application you own ```console $ lanes link connect discord --profile personal --workspace local ``` It asks for one thing, a bot token from Discord's developer portal. Everything else is choices you make in that portal, plus an invite link you open once per server. ## Read this part first **Discord has no API for acting as your own account.** Automating a user token is self-botting, their terms forbid it, and accounts get terminated for it. Every legitimate integration acts as an *application*, and an application's messages carry an `APP` badge next to the name. No setting, permission, or endpoint removes it. What you can control is the name and the avatar, two ways: - **As the application.** `create_message` posts under the application's own name and icon, which you set once in the portal. Set them to your name and photo and posts read as you, with the badge. - **As anything, per message.** `execute_webhook` takes `username` and `avatar_url` on each call, so one channel can carry posts under different names. This is the closer match to "post as myself". See [Posting under your own name](#posting-under-your-own-name). Reading has one gate that will waste an afternoon if you miss it: the **Message Content intent**. Without it every message comes back with an empty `content`, the call still returns `200`, and nothing says why. ## What your agent can do Twenty operations, out of 242 in Discord's API. | Capability | What it does | |---|---| | `discord.get_my_user` | Which application this token is | | `discord.list_my_guilds` | The servers the bot was added to. An empty list means the invite was missed | | `discord.get_guild` | One server, with optional member counts | | `discord.list_guild_channels` | How a channel name becomes the id every other call needs | | `discord.get_channel` | One channel: name, type, topic, category | | `discord.list_messages` | The triage read, and the only one. Pages on `before` and `after` | | `discord.get_message` | One message in full, with reactions and embeds | | `discord.list_pins` | What has been marked | | `discord.list_message_reactions_by_emoji` | Who reacted with one emoji | | `discord.get_active_guild_threads` | Every open thread in a server at once | | `discord.create_message` | Post as the application | | `discord.update_message` | Edit its own message. Discord shows an "edited" marker regardless | | `discord.delete_message` | The retraction, one message by id. There is deliberately no bulk delete | | `discord.crosspost_message` | Publish an announcement post to the servers that follow it | | `discord.add_my_message_reaction` | Mark a message seen or triaged. Notifies nobody | | `discord.create_pin` | The heavier mark. Needs Manage Messages, and 50 per channel is the cap | | `discord.create_thread_from_message` | Turn a post into a discussion | | `discord.list_channel_webhooks` | Find an existing webhook before making another | | `discord.create_webhook` | One per channel, once | | `discord.execute_webhook` | Post with `username` and `avatar_url` set per message | That list is the boundary, not a starting point. `connect` grants `discord.*`, and policy has no pattern between a whole provider and one exact name, so those twenty are everything an agent can reach. Excluded on purpose: `bulk_delete_messages`, every moderation endpoint, and everything under roles, invites, and guild settings. To go narrower, deny what you do not want. A deny beats an allow regardless of order: ```console $ lanes link policy deny discord.delete_message discord.create_webhook --profile personal --workspace local ``` **There is no message search.** Discord does not offer one to applications. Finding something means paging `list_messages` per channel and filtering yourself. **Attachments are not available** either: Discord takes files as `multipart/form-data`, which this connector does not encode. ## Setting up the application 1. Open [https://discord.com/developers/applications](https://discord.com/developers/applications) and choose **New Application**. The name you give it is the name on every post. 2. On **General Information**, set the icon. That is the avatar on every post. Copy the **Application ID** while you are here, because the invite link needs it. 3. Open the **Bot** tab and set the username. 4. Still on **Bot**, under **Privileged Gateway Intents**, switch on **MESSAGE CONTENT**. An application in fewer than 10,000 servers can just toggle it, with no review. Leave `PRESENCE` and `SERVER MEMBERS` off; nothing here uses them. 5. Turn **Public Bot** off, unless you want other people able to add it to their servers. ## The token On the **Bot** tab, choose **Reset Token** and copy what it shows you. Discord shows it once. **Paste it with the word `Bot` and a space in front:** ``` Bot MTIzNDU2Nzg5MDEyMzQ1Njc4.GhIjKl.mNoPqRsTuVwXyZ ``` That prefix is Discord's authentication scheme, the way `Bearer` is most other vendors'. The stored value goes into the `Authorization` header exactly as you type it, so a token pasted bare produces a `401` on every call with nothing in it to say what is wrong. If something is refusing to authenticate, check this first. The token is stored encrypted at `discord/` in the credential store, never in config. ## Inviting it to a server Take the Application ID from step 2 and open: ``` https://discord.com/oauth2/authorize?client_id=&scope=bot&permissions=309774593088 ``` Pick a server you own and authorise. Repeat per server. Those permission bits are exactly what the twenty operations need, and no more: View Channels, Send Messages, Send Messages in Threads, Read Message History, Add Reactions, Manage Messages (Discord puts pinning behind it), Create Public Threads, and Manage Webhooks. There is no kick, ban, timeout, role, or channel-management bit in there. **A private channel needs the bot added to it separately.** Server-wide permissions do not reach a channel the bot cannot see, so add it under that channel's own permission settings. ## Posting under your own name `create_message` posts as the application. To post as *you*: 1. `discord.list_channel_webhooks` on the channel. If one is already there, use it. A channel holds at most 15. 2. `discord.create_webhook` if not. Keep the `id` and `token` it returns. 3. `discord.execute_webhook` with `username` and `avatar_url` set to whatever the post should wear. **A webhook token is a credential.** Anybody holding it can post to that channel with no other authentication, and steps 1 and 2 both return it in their response, which means it reaches the agent and whatever the agent is talking to. That is recorded as a [NOT-GUARANTEED row in the security model](/docs/link/security#guarantee-status) rather than glossed over. It is withheld from the audit log, and it is bounded: one channel, no read access, nothing else. To revoke one, go to the channel's **Integrations → Webhooks** settings in Discord itself. Deleting the webhook there invalidates the token immediately. ## Connecting without a terminal to answer The connection is named by a label you type, so a scripted run has to supply one: ```console $ printf 'Bot %s' "$DISCORD_BOT_TOKEN" | lanes link secrets set discord/main --profile personal $ lanes link connect discord --display-name "Announcer" --non-interactive --profile personal --workspace local ``` Re-running `connect` with the **same** `--display-name` repairs the existing connection. A different label makes a second one, which is how you end up with `announcer2`. ## Troubleshooting | What you see | What it is | |---|---| | `401` on everything, `get_my_user` included | The `Bot ` prefix is missing, nine times out of ten. Otherwise the token was invalidated by a later Reset Token. Re-run `connect --replace` | | Reads work, every `content` is empty | The MESSAGE CONTENT intent is off. Bot tab, Privileged Gateway Intents. No error mentions it | | `403` on one channel, fine elsewhere | The bot is in the server but not that channel. A private channel does not inherit | | `crosspost_message` fails | It only works on an announcement channel (`type: 5`) and on a message already posted there | | `create_pin` fails with `403` | Pinning needs Manage Messages, and a channel holds at most 50 pins | | A webhook post returns `400` | `username` has to be 1 to 80 characters, and not "Clyde" or "Discord" | The empty-content case can look intermittent, because content is always available for messages that mention the application, messages in DMs with it, and its own messages. ## What is recorded Guild, channel, message, and webhook ids are kept, along with cursors, `allowed_mentions`, and `execute_webhook`'s `username`. Message content, embeds, components, attachments, polls, thread names, and `webhook_token` are withheld. **`allowed_mentions` is kept on purpose.** It is not content, it is blast radius. Whether a post was permitted to ping `@everyone` is exactly the thing you want in the log, and it is unrecoverable once the message is edited. See [the audit log](/docs/link/audit). --- **Next:** [every provider](/docs/link/providers), or [Add it to your agent](/docs/link/clients). Source: https://lanes.sh/docs/link/discord --- # Dropbox > Connect Dropbox to your agents with Lanes Link. Files, folders, shared links, and file requests. Files, folders, shared links, and file requests, via Dropbox's official MCP server. ## Connect it ```console $ lanes link connect dropbox --profile personal --workspace local ``` Dropbox registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Dropbox's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Dropbox's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'dropbox.*' --connection dropbox.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/dropbox --- # Expensify > Connect Expensify to your agents with Lanes Link. Expenses, reports, and receipts. Expenses, reports, and receipts, via Expensify's official MCP server. ## Connect it ```console $ lanes link connect expensify --profile personal --workspace local ``` Expensify registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Expensify's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Expensify's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'expensify.*' --connection expensify.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/expensify --- # Fastmail Calendar > Connect Fastmail Calendar to your agents with Lanes Link. Read and write events over CalDAV. Read and create events in Fastmail calendars over CalDAV. ## Connect it ```console $ lanes link connect fastmail_calendar --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `fastmail_contacts` and `fastmail_mail` as well, because they share a credential. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set fastmail/main --profile personal $ lanes link connect fastmail_calendar --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do CalDAV gives a fixed set of capabilities, the same on every server that speaks it: - `fastmail_calendar.list_calendars` - `fastmail_calendar.list_events` - `fastmail_calendar.get_event` - `fastmail_calendar.create_event` - `fastmail_calendar.update_event` - `fastmail_calendar.delete_event` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `calendar`, `start`, `end`, `limit`, and `uid` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'fastmail_calendar.*' --connection fastmail_calendar.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/fastmail-calendar --- # Fastmail Contacts > Connect Fastmail Contacts to your agents with Lanes Link. Look up an address over CardDAV. Search and read contacts in a Fastmail address book over CardDAV. ## Connect it ```console $ lanes link connect fastmail_contacts --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `fastmail_calendar` and `fastmail_mail` as well, because they share a credential. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set fastmail/main --profile personal $ lanes link connect fastmail_contacts --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do CardDAV gives a fixed set of capabilities, the same on every server that speaks it: - `fastmail_contacts.list_addressbooks` - `fastmail_contacts.search_contacts` - `fastmail_contacts.create_contact` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `addressbook` and `limit` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'fastmail_contacts.*' --connection fastmail_contacts.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/fastmail-contacts --- # Fastmail Mail > Connect Fastmail Mail to your agents with Lanes Link. Read, search, and send over IMAP and SMTP. Read, search, and send mail in a Fastmail mailbox over IMAP and SMTP, with an app password that does not expire. ## Connect it ```console $ lanes link connect fastmail_mail --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `fastmail_calendar` and `fastmail_contacts` as well, because they share a credential. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set fastmail/main --profile personal $ lanes link connect fastmail_mail --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do IMAP and SMTP give a fixed set of capabilities, the same on every mailbox: - `fastmail_mail.list_mailboxes` - `fastmail_mail.search_messages` - `fastmail_mail.get_message` - `fastmail_mail.get_attachment` - `fastmail_mail.mark_messages` - `fastmail_mail.move_messages` - `fastmail_mail.send_message` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `mailbox`, `limit`, `unseen`, `flagged`, `uid`, and `message_id` are kept. Everything that carries your words is withheld. `fastmail_mail.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'fastmail_mail.*' --connection fastmail_mail.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/fastmail-mail --- # Figma > Connect Figma to your agents with Lanes Link. Files, designs, components, and Dev Mode context. Files, designs, components, and Dev Mode context, via Figma's official MCP server. ## Connect it ```console $ lanes link connect figma --profile personal --workspace local ``` Figma registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Figma's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Figma's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'figma.*' --connection figma.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/figma --- # Fireflies > Connect Fireflies to your agents with Lanes Link. Meeting transcripts, summaries, and action items. Meeting transcripts, summaries, and action items, via Fireflies's official MCP server. ## Connect it ```console $ lanes link connect fireflies --profile personal --workspace local ``` Fireflies registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Fireflies's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Fireflies's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'fireflies.*' --connection fireflies.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/fireflies --- # Flagsmith > Connect Flagsmith to your agents with Lanes Link. Feature flags, segments, and environments. Feature flags, segments, and environments, via Flagsmith's official MCP server. ## Connect it ```console $ lanes link connect flagsmith --profile personal --workspace local ``` Flagsmith registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Flagsmith's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Flagsmith's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'flagsmith.*' --connection flagsmith.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/flagsmith --- # Gamma > Connect Gamma to your agents with Lanes Link. Presentations and documents, generated and read back. Presentations and documents, generated and read back, via Gamma's official MCP server. ## Connect it ```console $ lanes link connect gamma --profile personal --workspace local ``` Gamma registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Gamma's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Gamma's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'gamma.*' --connection gamma.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/gamma --- # GitHub > Repositories, issues, pull requests, and workflow runs ```console $ lanes link connect github --profile personal --workspace local ``` You are asked for one fine-grained personal access token. There is no browser consent, no OAuth client, and nothing to register. ## What your agent can do GitHub runs its own MCP server, and serves a different tool list per *toolset*. This connection asks for `context`, `repos`, `issues`, `pull_requests`, `actions`, and `labels`, which is what an agent working in a repository actually uses. The full set is considerably larger and is more than most agents reason over well. `lanes link status` lists what your connection actually discovered. ## The token 1. Open **[github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)** and choose **Generate new token**. 2. Name it `Lanes Link`. The name is how you revoke this one later without touching your other tokens. Set an expiry you are willing to renew. 3. **Resource owner**: yourself, or the organisation whose repositories you want reachable. An organisation may require an owner to approve the token first, and until they do it authenticates and returns nothing. 4. **Repository access**: only the repositories you want an agent to see. *All repositories* is the setting people regret. 5. **Permissions**, matching the toolsets above: | Permission | Level | |---|---| | Contents | Read | | Metadata | Read *(added for you)* | | Issues | Read and write | | Pull requests | Read and write | | Actions | Read | Add *Administration* or *Workflows* only if you know you need them. 6. **Generate**, then copy the token. GitHub shows it once, and it starts with `github_pat_`. A classic token works too, with the `repo` scope, but it is all or nothing across every repository you can reach. Prefer the fine-grained one. The token goes into the encrypted credential store at `github/`, never into config. ## Renewing it A fine-grained token expires. When it does, generate another and run: ```console $ lanes link connect github --replace --profile personal --workspace local ``` Without `--replace`, connect finds the expired token already stored and reuses it. Same shape as iCloud's app-specific password, and for the same reason: the stored credential is the one that was just refused. ## A read-only connection GitHub also serves a read-only variant. It is a manifest of your own rather than a flag: ```yaml # ~/.lanes-link/data//providers.d/github-readonly.yaml id: github_readonly name: GitHub (read-only) connector: kind: mcp endpoint: https://api.githubcopilot.com/mcp/readonly auth: kind: bearer identity: kind: http url: https://api.github.com/user field: login setup: prompts: - key: token label: GitHub personal access token secret: true scope: connection ``` That is a separate provider with its own token and its own policy line, which is the point: you can grant one profile the read-only connection and never the other. ## Connecting without a terminal to answer GitHub is the straightforward case for an agent with a shell, because nothing here needs a browser: ```console $ printf %s "$GITHUB_TOKEN" | lanes link secrets set github/octocat --profile personal $ lanes link connect github --id octocat --non-interactive --json --profile personal ``` The credential goes in on stdin, never as a flag. An argument lands in shell history, in `ps` output, and in any transcript. ## Why a token rather than a browser sign-in Notion and Linear support Dynamic Client Registration, so Lanes Link registers itself and you do nothing. GitHub does not offer that yet, and the documented alternative fails on a detail: an OAuth App matches its callback URL exactly, port included, and `connect` listens on a port the kernel picks per run, so there is no port to register. GitHub's remote MCP server accepts a personal access token as `Authorization: Bearer`, which is the credential GitHub issues for exactly this case. ## Troubleshooting | What you see | What it is | |---|---| | `GitHub refused the token` | The token expired, the repository was not in the set you granted, or an organisation token is still waiting on an owner's approval | | The connection is labelled with something you typed rather than your login | The identity probe could not reach `api.github.com/user`, which nearly always means the token is wrong | | A tool you expected is missing | Check the toolsets above, then `lanes link status` | ## What is recorded Repository, issue, and pull request identifiers are kept, along with the method. Bodies and comment text are withheld, and reads reduce every argument to a type marker. One caveat worth stating rather than leaving to be discovered: GitHub's MCP server is GitHub's, so capabilities are discovered at connect time and nothing here can check the argument names against theirs. If GitHub renames an argument, the value is withheld and the log reads exactly as it does when redaction is working. `lanes link doctor` reporting capability drift is the signal that the list wants re-reading. See [the audit log](/docs/link/audit). --- **Next:** [every provider](/docs/link/providers), or [Add it to your agent](/docs/link/clients). Source: https://lanes.sh/docs/link/github --- # Gmail > Connect Gmail to your agents with Lanes Link. Read, search, send, draft, and organise mail. Read, search, send, draft, and organise mail, including labels, read-state, spam, and trash, via the Gmail REST API. ## Connect it ```console $ lanes link connect gmail --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/gmail.readonly` | read mail and settings | | `https://www.googleapis.com/auth/gmail.compose` | create and send drafts | | `https://www.googleapis.com/auth/gmail.modify` | read, send, and modify any message; no permanent delete **(broad)** | | `https://www.googleapis.com/auth/gmail.settings.basic` | create and delete filters, and change your send-as identities; a filter is a standing rule that keeps acting after the session ends **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. **Not asked for:** - `mail.google.com`. The only scope that permanently deletes. Nothing here offers permanent deletion, so trash and untrash are the whole of it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `userId`, `id`, `addLabelIds`, `removeLabelIds`, `ids`, and `pathId` are kept. Everything that carries your words is withheld. `gmail.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'gmail.*' --connection gmail.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/gmail --- # Gmail (Google MCP) > Connect Gmail (Google MCP) to your agents with Lanes Link. Google's own MCP server. Developer Preview only. Read and compose mail via Google's official Gmail MCP server. Requires Workspace Developer Preview enrolment; use "gmail" otherwise. ## Connect it ```console $ lanes link connect gmail_mcp --profile personal --workspace local ``` Gmail (Google MCP) does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan gmail_mcp` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do The tool list comes from Gmail (Google MCP)'s own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/gmail.readonly` | read mail and settings | | `https://www.googleapis.com/auth/gmail.compose` | create and send drafts | How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Gmail (Google MCP)'s rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'gmail_mcp.*' --connection gmail_mcp.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/gmail-mcp --- # Gmail (IMAP) > Connect Gmail (IMAP) to your agents with Lanes Link. The same mailbox over IMAP and SMTP, with an app password that does not expire. Read, search, and send mail in a personal Gmail mailbox over IMAP and SMTP, with an app password that does not expire. ## Connect it ```console $ lanes link connect gmail_imap --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set gmail_imap/main --profile personal $ lanes link connect gmail_imap --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do IMAP and SMTP give a fixed set of capabilities, the same on every mailbox: - `gmail_imap.list_mailboxes` - `gmail_imap.search_messages` - `gmail_imap.get_message` - `gmail_imap.get_attachment` - `gmail_imap.mark_messages` - `gmail_imap.move_messages` - `gmail_imap.send_message` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `mailbox`, `limit`, `unseen`, `flagged`, `uid`, and `message_id` are kept. Everything that carries your words is withheld. `gmail_imap.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'gmail_imap.*' --connection gmail_imap.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/gmail-imap --- # Google > Gmail, Drive, Sheets, Docs, Calendar, Tasks, and Contacts ```console $ lanes link connect gmail --profile personal --workspace local ``` That is the whole of it. A browser opens, you approve the scopes, the connection is made. There is no Cloud project to create and no client to register: by default Lanes Link authorises against a client Lanes operates, whose secret stays in the Lanes API and never reaches your machine. Two things that costs you, both worth knowing before you start: - Until Google's verification completes you see a **"Google hasn't verified this app"** screen. Choose **Advanced → Go to Lanes** to continue. - The hosted client is limited to **100 Google accounts**, a cap Google counts for the lifetime of the project. `connect` warns as it fills. If neither suits you, [register a client of your own](/docs/link/google-own-client). ## Each product is its own connection Connecting Gmail does not imply Drive, Sheets, Docs, Calendar, Tasks, or Contacts. Each holds its own token under its own scopes. One OAuth client covers all seven, so adding the second costs no new credentials, but each needs its own API enabled and its own scopes registered. `connect` prints that console work the first time you connect each product. ```console $ lanes link connect drive --profile personal --workspace local $ lanes link connect calendar --profile personal --workspace local ``` ## What your agent can do **Gmail.** Search with Gmail's own `q` syntax, then retrieve by id. Read threads, labels, drafts, and attachments. Send mail or write a draft; send or discard a draft that exists. Archive, mark read, report spam, and move between folders, which in Gmail are all one operation on labels. Trash and untrash, which are recoverable. Create and delete filters, which is how a sender is blocked. There is **no permanent delete**. That needs the `mail.google.com` scope, which this refuses to ask for. **Drive.** Search files and read metadata, export a Google-native file to a portable format, and see who a file is shared with. Create, copy, update, move, rename, delete, and share. Creating a spreadsheet, document, or presentation is also `drive.files.create`, with the right `mimeType`. Every write is bounded by the `drive.file` scope, so an agent can only touch files it created or that you opened with it. Reading is `drive.readonly`. **Sheets and Docs.** Read a spreadsheet's structure without its cells, read one range or several at once, then write, append, or clear. Everything structural (tabs, formatting, frozen rows, charts) goes through `batchUpdate`. Docs reads the document as a structure, which is how you find the index to edit at, and every edit is `documents.batchUpdate`. **Calendar, Tasks, and Contacts.** List calendars and events, expand a recurring event, and answer "when am I free" in one call across calendars whose contents the token may not read. Create, patch, delete, and move events. The same lifecycle for task lists and tasks, where completing a task is a patch with a status. Contacts is **read-only and cannot enumerate**: there is no "list all my contacts". It searches, so "email Bob" resolves to an address, including over addresses Gmail saved automatically. Nothing here destroys a container. Deleting a task list would take every task with it, so it is not offered. ## The weekly re-authorisation This is the one thing about Google worth understanding before you rely on it. It is a property of the OAuth client's **publishing status**, and nothing else. A client in Testing has every refresh token it issues expired after seven days; a client in production does not. The hosted client is under review, and a client under review has whatever status it has, so connections made against it expire weekly too until that lands. When one dies, a call fails with a message naming the cause and the fix: ``` The refresh token for gmail.work has expired or been revoked. Re-authorise with: lanes link connect gmail.work ``` `lanes link doctor` reports stale connections before you hit them: ```console $ lanes link doctor --profile personal --workspace local warn gmail.personal credential is 8 days old. Testing-status apps expire at 7. Run: lanes link connect gmail.personal ``` The ways out, cheapest first: | Escape | What it costs | |---|---| | [Register your own client as Internal](/docs/link/google-own-client) | About ten minutes, once. Needs a Google Workspace organisation, and every account on that domain. Nothing to maintain afterwards | | [Register your own as External, and publish it](/docs/link/google-own-client) | About twenty minutes. An unverified-app screen for everyone you connect, and a lifetime cap of 100 new users on that project. This is the one most people want | | [An app password over IMAP](#gmail-over-imap) | Personal accounts only, mail only. Nothing expires and there is no console project at all | | [A service account key](/docs/link/google-own-client#connecting-with-a-service-account-key) | Nothing expires, because nothing consented. It reaches less, and how much less depends on the product | | Complete Google's verification | Weeks to months, and for restricted scopes a paid third-party security assessment | Anything else claiming to avoid this is either using non-restricted scopes or is about to stop working. ## Gmail over IMAP If this is a personal `@gmail.com` and mail is what you need, there is a route with nothing to expire and no console project at all: ```console $ lanes link connect gmail_imap --profile personal --workspace local ``` That is a different provider from `gmail` rather than another route into it, because a manifest has one connector and IMAP is not HTTPS. | | `gmail` | `gmail_imap` | |---|---|---| | Credential | OAuth token, or a service account key | An app password | | Expires | with the client's publishing status | **never** | | Works on Workspace | yes | no. Google ended basic auth there in March 2025 | | Reaches | Gmail's API: labels, threads, drafts, the lot | a mailbox: search, read, flag, move, send | | Policy rule | `gmail.*` | `gmail_imap.*` | They are separate connections and can both exist. Nothing shares a credential between them. **Getting the password.** Two-Step Verification has to be on first, or the app-passwords page reports that the setting is unavailable rather than saying why. Then [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords), create one, and name it "Lanes Link", which is the only way to revoke this one later without cutting off your other devices. Google shows the sixteen characters once, in four groups of four. The spaces are cosmetic. If a login is refused, it is almost always the account password pasted where the app password belongs, because IMAP reports both the same way. `lanes link connect gmail_imap --replace` is the fix; a bare re-run finds the refused credential already stored and reuses it. ## When something goes wrong | Symptom | Cause | |---|---| | `invalid_grant`, roughly weekly | Testing publishing status. See [the weekly re-authorisation](#the-weekly-re-authorisation) | | `invalid_grant` once, after months of working | Not an expiry. The consent was revoked: a Workspace admin withdrawing third-party access, a password change, or a removal at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) | | `invalid_grant` immediately | The account is not in **Test users**, or the grant was revoked | | 403 "Insufficient Permission" | The API is not enabled, or the consent did not include the scope | | 403 on the Gmail organising tools only | The consent predates `gmail.modify`. Add it under **Data access**, then re-run `connect`. A refresh keeps the old grant and will not pick it up | | 403 on the Gmail filter tools only | The consent predates `gmail.settings.basic`. `filters_list` still working is the tell, because it accepts `gmail.readonly` | | 403 on every Calendar call, but consent worked | The enabled service is `calendar.googleapis.com`. Calendar's is **`calendar-json.googleapis.com`** | | Tools list fine, every call says "The caller does not have permission" | You are on `gmail_mcp` or `drive_mcp` without Workspace Developer Preview enrolment. Switch to `gmail` or `drive` | | No refresh token returned | The account already authorised this app. Revoke at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and retry | | The tool list did not change after a version bump | The served list comes from the discovery cache. Re-run `connect` to refresh it | | Mailboxes missing after an IMAP login succeeds | Gmail → Settings → See all settings → Forwarding and POP/IMAP → IMAP access | `lanes link audit tail` shows what was actually attempted, with arguments redacted. --- **Next:** [Add it to your agent](/docs/link/clients). If you need your own OAuth client, a service account, or the Workspace MCP servers, that is [Registering your own Google client](/docs/link/google-own-client). Source: https://lanes.sh/docs/link/google --- # Google Calendar > Connect Google Calendar to your agents with Lanes Link. Read and write events, and answer when you are free. Read and write calendar events: list, search, create, reschedule, and cancel. It also answers when you are free, via the Calendar REST API. ## Connect it ```console $ lanes link connect calendar --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/calendar.readonly` | read every calendar you can access | | `https://www.googleapis.com/auth/calendar.events` | every event on every calendar; read, create, change, and delete **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `minAccessRole`, `showHidden`, `maxResults`, `calendarId`, `timeMin`, and `timeMax` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'calendar.*' --connection calendar.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/google-calendar --- # Google Contacts > Connect Google Contacts to your agents with Lanes Link. Look up an address, so 'email Bob' resolves. Read-only. Look up a saved contact by name to find their address or phone number, including the addresses Gmail saved automatically. Read-only, via the People REST API. ## Connect it ```console $ lanes link connect contacts --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/contacts.readonly` | read your saved contacts | | `https://www.googleapis.com/auth/contacts.other.readonly` | read the addresses Gmail saved automatically in "Other contacts" | How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `pageSize`, `readMask`, `resourceNames`, and `personFields` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'contacts.*' --connection contacts.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/google-contacts --- # Google Docs > Connect Google Docs to your agents with Lanes Link. Read a document and edit its content. Read a document's structure and edit its content: insert, replace, and format text, via the Docs REST API. ## Connect it ```console $ lanes link connect docs --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/drive.readonly` | read every file in the account | | `https://www.googleapis.com/auth/drive.file` | only files this app creates or you pick | | `https://www.googleapis.com/auth/documents` | every document in the account; read, edit, create, and delete **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `documentId` and `suggestionsViewMode` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'docs.*' --connection docs.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/google-docs --- # Google Drive > Connect Google Drive to your agents with Lanes Link. Search, read, export, and share files. Search, read, and export files, and organise the ones this app created: rename, move, trash, copy, and share, via the Drive REST API. ## Connect it ```console $ lanes link connect drive --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/drive.readonly` | read every file in the account | | `https://www.googleapis.com/auth/drive.file` | only files this app creates or you pick | **Not asked for:** - the unrestricted `drive` scope. Writes are bounded by `drive.file`, so an agent can only touch files it created or that you opened with it. Reading is `drive.readonly`. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `pageSize`, `orderBy`, `spaces`, `includeItemsFromAllDrives`, `fileId`, and `acknowledgeAbuse` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'drive.*' --connection drive.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/google-drive --- # Google Drive (Google MCP) > Connect Google Drive (Google MCP) to your agents with Lanes Link. Likewise; use `drive` unless you are enrolled. Search, read, and create files via Google's official Drive MCP server. Requires Workspace Developer Preview enrolment; use "drive" otherwise. ## Connect it ```console $ lanes link connect drive_mcp --profile personal --workspace local ``` Google Drive (Google MCP) does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan drive_mcp` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do The tool list comes from Google Drive (Google MCP)'s own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/drive.readonly` | read every file in the account | | `https://www.googleapis.com/auth/drive.file` | only files this app creates or you pick | How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Google Drive (Google MCP)'s rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'drive_mcp.*' --connection drive_mcp.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/drive-mcp --- # Google Sheets > Connect Google Sheets to your agents with Lanes Link. Read and edit cells, ranges, and tabs. Read and edit spreadsheet cells: ranges, appends, and structural changes like tabs, formatting, and frozen rows, via the Sheets REST API. ## Connect it ```console $ lanes link connect sheets --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/drive.readonly` | read every file in the account | | `https://www.googleapis.com/auth/drive.file` | only files this app creates or you pick | | `https://www.googleapis.com/auth/spreadsheets` | every spreadsheet in the account; read, edit, create, and delete **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `spreadsheetId`, `ranges`, `includeGridData`, `range`, `majorDimension`, and `pathRange` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'sheets.*' --connection sheets.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/google-sheets --- # Google Tasks > Connect Google Tasks to your agents with Lanes Link. Create, edit, complete, and reorder tasks. Read and write task lists and tasks: create, edit, complete, reorder, and delete, via the Tasks REST API. ## Connect it ```console $ lanes link connect google_tasks --profile personal --workspace local ``` A browser opens and you approve. The OAuth client is operated by Lanes, so no client secret of theirs is stored on your machine. Pass `--own-client` to use an application you registered yourself instead, which [the Google guide](/docs/link/google) walks through. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `https://www.googleapis.com/auth/tasks` | every task list; read, create, edit, organise, and delete **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `maxResults`, `tasklist`, `showCompleted`, `showDeleted`, `showHidden`, and `dueMin` are kept. Everything that carries your words is withheld. `google_tasks.tasks.tasklists.insert` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'google_tasks.*' --connection google_tasks.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/google-tasks --- # Grafana > Connect Grafana to your agents with Lanes Link. Dashboards, datasources, queries, and alert rules. Dashboards, datasources, queries, and alert rules, via Grafana's official MCP server. ## Connect it ```console $ lanes link connect grafana --profile personal --workspace local ``` Grafana registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Grafana's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Grafana's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'grafana.*' --connection grafana.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/grafana --- # Heroku > Connect Heroku to your agents with Lanes Link. Apps, dynos, add-ons, releases, and logs. Apps, dynos, add-ons, releases, and logs, via Heroku's official MCP server. ## Connect it ```console $ lanes link connect heroku --profile personal --workspace local ``` Heroku registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Heroku's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Heroku's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'heroku.*' --connection heroku.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/heroku --- # HubSpot > Connect HubSpot to your agents with Lanes Link. CRM records, needs an MCP auth app of your own. CRM contacts, companies, deals, and engagements, via HubSpot's official MCP server. ## Connect it ```console $ lanes link connect hubspot --profile personal --workspace local ``` HubSpot does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan hubspot` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do The tool list comes from HubSpot's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are HubSpot's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'hubspot.*' --connection hubspot.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/hubspot --- # Hygraph > Connect Hygraph to your agents with Lanes Link. Content entries, models, and schema. Content entries, models, and schema, via Hygraph's official MCP server. ## Connect it ```console $ lanes link connect hygraph --profile personal --workspace local ``` Hygraph registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Hygraph's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Hygraph's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'hygraph.*' --connection hygraph.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/hygraph --- # iCloud > Mail, Calendar, and Contacts from one Apple Account, plus your iCloud Drive folder ```console $ lanes link connect icloud --profile personal --workspace local ``` That connects Mail, Calendar, and Contacts together. You are asked for your Apple Account address and one app-specific password, once, because Apple issues app-specific passwords at *account* scope. iCloud Drive is separate and needs no credential at all. See [iCloud Drive works differently](#icloud-drive-works-differently) below. ## What your agent can do | | | |---|---| | **iCloud Mail** | `list_mailboxes`, `search_messages`, `get_message`. Write: `mark_messages`, `move_messages`, `send_message` | | **iCloud Calendar** | `list_calendars`, `list_events`, `get_event`. Write: `create_event`, `update_event`, `delete_event` | | **iCloud Contacts** | `list_addressbooks`, `search_contacts`. Write: `create_contact` | | **iCloud Drive** | `list_files`, `search_files`, `read_file`, `file_info`. Write: `write_file`, `move_file`, `create_folder`, `trash_file` | These are three separate providers, which is what lets you cut one without touching the others. `connect` grants each whole provider, writes included, so sending mail and creating events work immediately and narrowing is something you do rather than something you undo: ```console $ lanes link policy deny icloud_mail.send_message --profile personal --workspace local # read mail, never send it $ lanes link policy deny 'icloud_mail.*' --profile personal --workspace local # calendars and contacts still work ``` **Reading never marks mail as read.** Every read path opens the mailbox with `EXAMINE` and fetches with `BODY.PEEK`, so an agent reading your inbox does not change what you see in Mail. Marking something read is a separate, write-bundle capability. **Nothing here can delete mail.** There is no `EXPUNGE`, and `\Deleted` is not a flag an agent can set. Moving a message to Trash is available and reversible; permanent deletion is not offered. **Editing an event does not disturb the rest of it.** `update_event` patches what is there rather than rebuilding it, so attendees and their replies, alarms, repetition rules, and anything another client wrote all survive a change of time or title. Both `update_event` and `delete_event` are conditional on the version they read, so if you edited the event on your phone in between, the write is refused rather than overwriting quietly. **Editing a contact is not offered.** vCard round-trips are where fields get destroyed, silently and permanently. Creating and searching carry no such risk. ## The app-specific password Your normal Apple Account password will not work. Apple refuses it for third-party clients, and there is no way around that. 1. Sign in at **[account.apple.com](https://account.apple.com)** and open **Sign-In and Security**. 2. **Two-factor authentication must be on.** Without it Apple does not offer app-specific passwords at all, and the section below simply does not appear. 3. **App-Specific Passwords → Generate**. Name it `Lanes Link`, because the name is the only way to revoke this one later without cutting off your other devices. 4. Copy the sixteen characters. Apple shows them once, formatted `xxxx-xxxx-xxxx-xxxx`. The hyphens are cosmetic and either form is accepted. You can hold **25** at a time, and revoke them individually. > **Changing your Apple Account password revokes every app-specific password at once.** This is the > cause of most sudden iCloud failures: everything works for weeks and then all three services stop > together. Generate a new password and run `lanes link connect icloud --replace`, which re-prompts > and updates all three. Without `--replace`, connect finds the revoked password already stored and > reuses it. The password goes into the encrypted credential store, never into config. It is stored once at `icloud/` and shared by the three providers. ## iCloud Drive works differently ```console $ lanes link connect icloud_drive --profile personal --workspace local ``` No password, no browser, nothing to type. Apple publishes no protocol for Drive at all, but on a Mac it is a folder the system keeps in sync, so this reads it directly. That has one consequence worth understanding: **it only works on the Mac holding the files.** There is no credential involved, so there is nothing that could be copied to a server elsewhere. The permission is macOS's, held against the process on that machine. - **Nothing deletes permanently.** `trash_file` moves to the Finder's Trash. - **Everything stays inside the folder.** Paths are resolved through symlinks before being checked, so a link pointing out of iCloud Drive is refused rather than followed. `.git`, `.ssh`, and `node_modules` are never reachable. - If "Optimise Mac Storage" has evicted a file, reading it says so and tells you how to fetch it, rather than returning the placeholder's contents as if they were the file. You may need to grant your terminal **Files and Folders** access in System Settings, Privacy and Security, the first time. ## What is not available - **Reminders and Notes.** Apple moved to-do lists to a private store after iOS 13, so CalDAV returns legacy data or empty tombstones, and Notes were never exposed over any open protocol. Neither is reachable by any third-party client, not just this one. - **Attachment contents, when reading.** `get_message` reports each attachment's name, type, and size, but not its bytes. Sending attachments does work, and forwarding one that arrived by mail is done by naming it (`{ "message_id": "<...>" }`), which never materialises the bytes. - **A display name, unless you set one.** SMTP sends exactly what is composed, so without `config.from_name` on the connection the `From` header is a bare address. ## Limits worth knowing - Event queries are capped at **one year** per request; iCloud rejects wider windows. - There is no push. Nothing here subscribes; an agent asks when it wants to know. - iCloud throttles reconnection harder than open sessions, so the mail connector holds one connection per account and reuses it. ## Is there an OAuth option? Apple shipped one in October 2025, and Outlook uses it. It is partner-gated: the developer service behind it publishes no scopes for these services, so it is not available to write against. If that changes it is a small change here, because auth is orthogonal to connectivity: the three manifests would swap `auth: basic` for `auth: oauth` and nothing about the connectors would move. ## Troubleshooting | What you see | What it is | |---|---| | `The server rejected the credential` | An Apple Account password used where an app-specific one belongs, or a password revoked by an account password change. Generate a new one and run `connect icloud --replace` | | A first connect with a mistyped password | Filed under `icloud/pending` and re-prompted automatically, so it needs no flag | | Two accounts | Connect each in turn. A second `connect icloud_mail` asks which account, or pass `--id` to name one | | Authentication fails with a local part as the username | Give the full address. It works for both IMAP and SMTP, and SMTP requires it | The `--replace` on the first row matters: the credential that was refused is the one in the store, so a bare re-run reuses it rather than asking. Nothing is discarded until you have entered the new password in full, so cancelling at the prompt leaves the old one alone. --- **Next:** [Add it to your agent](/docs/link/clients). For what the audit log keeps and withholds, see the [capability reference](/docs/link/capabilities). Source: https://lanes.sh/docs/link/icloud --- # iCloud Calendar > Connect iCloud Calendar to your agents with Lanes Link. Read and write events over CalDAV. Read and create events in iCloud calendars over CalDAV. ## Connect it ```console $ lanes link connect icloud_calendar --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `icloud_contacts` and `icloud_mail` as well, because they share a credential. [The iCloud guide](/docs/link/icloud) has the steps for generating one. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set icloud/main --profile personal $ lanes link connect icloud_calendar --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do CalDAV gives a fixed set of capabilities, the same on every server that speaks it: - `icloud_calendar.list_calendars` - `icloud_calendar.list_events` - `icloud_calendar.get_event` - `icloud_calendar.create_event` - `icloud_calendar.update_event` - `icloud_calendar.delete_event` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `calendar`, `start`, `end`, `limit`, and `uid` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'icloud_calendar.*' --connection icloud_calendar.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/icloud-calendar --- # iCloud Contacts > Connect iCloud Contacts to your agents with Lanes Link. Look up an address over CardDAV. Search and read contacts in an iCloud address book over CardDAV. ## Connect it ```console $ lanes link connect icloud_contacts --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `icloud_calendar` and `icloud_mail` as well, because they share a credential. [The iCloud guide](/docs/link/icloud) has the steps for generating one. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set icloud/main --profile personal $ lanes link connect icloud_contacts --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do CardDAV gives a fixed set of capabilities, the same on every server that speaks it: - `icloud_contacts.list_addressbooks` - `icloud_contacts.search_contacts` - `icloud_contacts.create_contact` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `addressbook` and `limit` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'icloud_contacts.*' --connection icloud_contacts.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/icloud-contacts --- # iCloud Drive > Connect iCloud Drive to your agents with Lanes Link. Your sync folder, on the Mac that syncs it. Read and organise files in iCloud Drive, on the Mac that syncs them. ## Connect it ```console $ lanes link connect icloud_drive --profile personal --workspace local ``` There is nothing to authenticate to. It reads a folder that is already on your machine, so the files are reachable the moment the connection exists. ## What your agent can do A folder is a folder, so the capabilities are fixed: - `icloud_drive.list_files` - `icloud_drive.read_file` - `icloud_drive.search_files` - `icloud_drive.file_info` - `icloud_drive.write_file` - `icloud_drive.move_file` - `icloud_drive.create_folder` - `icloud_drive.trash_file` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `path`, `recursive`, `limit`, `overwrite`, `from`, and `to` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'icloud_drive.*' --connection icloud_drive.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/icloud-drive --- # iCloud Mail > Connect iCloud Mail to your agents with Lanes Link. Read, search, and send over IMAP and SMTP. Read, search, and send mail in an iCloud mailbox over IMAP and SMTP. ## Connect it ```console $ lanes link connect icloud_mail --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `icloud_calendar` and `icloud_contacts` as well, because they share a credential. [The iCloud guide](/docs/link/icloud) has the steps for generating one. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set icloud/main --profile personal $ lanes link connect icloud_mail --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do IMAP and SMTP give a fixed set of capabilities, the same on every mailbox: - `icloud_mail.list_mailboxes` - `icloud_mail.search_messages` - `icloud_mail.get_message` - `icloud_mail.get_attachment` - `icloud_mail.mark_messages` - `icloud_mail.move_messages` - `icloud_mail.send_message` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `mailbox`, `limit`, `unseen`, `flagged`, `uid`, and `message_id` are kept. Everything that carries your words is withheld. `icloud_mail.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'icloud_mail.*' --connection icloud_mail.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/icloud-mail --- # Insightly > Connect Insightly to your agents with Lanes Link. Contacts, organisations, opportunities, and projects. Contacts, organisations, opportunities, and projects, via Insightly's official MCP server. ## Connect it ```console $ lanes link connect insightly --profile personal --workspace local ``` Insightly registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Insightly's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Insightly's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'insightly.*' --connection insightly.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/insightly --- # Jam > Connect Jam to your agents with Lanes Link. Bug reports, with console logs, network calls, and repro steps. Bug reports, with their console logs, network calls, and repro steps, via Jam's official MCP server. ## Connect it ```console $ lanes link connect jam --profile personal --workspace local ``` Jam registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Jam's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Jam's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'jam.*' --connection jam.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/jam --- # Klaviyo > Connect Klaviyo to your agents with Lanes Link. Profiles, lists, segments, campaigns, and flows. Profiles, lists, segments, campaigns, and flows, via Klaviyo's official MCP server. ## Connect it ```console $ lanes link connect klaviyo --profile personal --workspace local ``` Klaviyo registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Klaviyo's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Klaviyo's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'klaviyo.*' --connection klaviyo.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/klaviyo --- # Linear > Connect Linear to your agents with Lanes Link. Linear's own MCP server. Issues, projects, comments, and cycles, via Linear's official MCP server. ## Connect it ```console $ lanes link connect linear --profile personal --workspace local ``` Linear registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Linear's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `read` | read posts and comments | | `write` | create and modify | How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Linear's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'linear.*' --connection linear.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/linear --- # Mailbox (any IMAP server) > Connect Mailbox (any IMAP server) to your agents with Lanes Link. Read, search, and send on a host with no entry of its own. Read, search, and send mail in any IMAP mailbox, whether a company server or a host with no provider of its own. ## Connect it ```console $ lanes link connect mailbox --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set mailbox/main --profile personal $ lanes link connect mailbox --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do IMAP and SMTP give a fixed set of capabilities, the same on every mailbox: - `mailbox.list_mailboxes` - `mailbox.search_messages` - `mailbox.get_message` - `mailbox.get_attachment` - `mailbox.mark_messages` - `mailbox.move_messages` - `mailbox.send_message` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `mailbox`, `limit`, `unseen`, `flagged`, `uid`, and `include_body` are kept. Everything that carries your words is withheld. `mailbox.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'mailbox.*' --connection mailbox.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/mailbox --- # Mercury > Connect Mercury to your agents with Lanes Link. Accounts, balances, transactions, and cards. Accounts, balances, transactions, and cards, via Mercury's official MCP server. ## Connect it ```console $ lanes link connect mercury --profile personal --workspace local ``` Mercury registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Mercury's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Mercury's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'mercury.*' --connection mercury.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/mercury --- # Microsoft To Do > Connect Microsoft To Do to your agents with Lanes Link. Create, edit, complete, and organise tasks and lists. Create, edit, complete, and organise tasks and lists in Microsoft To Do. ## Connect it ```console $ lanes link connect microsoft_todo --profile personal --workspace local ``` Microsoft To Do does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan microsoft_todo` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `offline_access` | keep the connection working without sending you to a browser again; Microsoft issues no refresh token without it | | `User.Read` | your name and address, so this connection can be labelled with whose it is. No mail, calendar, or files | | `Tasks.ReadWrite` | read and write every list and task in Microsoft To Do **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `top`, `orderby`, `select`, `todoTaskList-id`, `todoTask-id`, and `importance` are kept. Everything that carries your words is withheld. `microsoft_todo.me.todo.CreateLists` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'microsoft_todo.*' --connection microsoft_todo.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/microsoft-todo --- # Miro > Connect Miro to your agents with Lanes Link. Boards, frames, sticky notes, and shapes. Boards, frames, sticky notes, and shapes, via Miro's official MCP server. ## Connect it ```console $ lanes link connect miro --profile personal --workspace local ``` Miro registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Miro's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Miro's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'miro.*' --connection miro.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/miro --- # Mixpanel > Connect Mixpanel to your agents with Lanes Link. Events, funnels, retention, and cohorts. Events, funnels, retention, and cohorts, via Mixpanel's official MCP server. ## Connect it ```console $ lanes link connect mixpanel --profile personal --workspace local ``` Mixpanel registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Mixpanel's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Mixpanel's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'mixpanel.*' --connection mixpanel.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/mixpanel --- # monday.com > Connect monday.com to your agents with Lanes Link. Boards, items, groups, columns, and updates. Boards, items, groups, columns, and updates, via monday.com's official MCP server. ## Connect it ```console $ lanes link connect monday --profile personal --workspace local ``` monday.com registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from monday.com's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are monday.com's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'monday.*' --connection monday.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/monday --- # Mux > Connect Mux to your agents with Lanes Link. Video assets, live streams, and playback analytics. Video assets, live streams, and playback analytics, via Mux's official MCP server. ## Connect it ```console $ lanes link connect mux --profile personal --workspace local ``` Mux registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Mux's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Mux's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'mux.*' --connection mux.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/mux --- # Navan > Connect Navan to your agents with Lanes Link. Trips, bookings, and travel expenses. Trips, bookings, and travel expenses, via Navan's official MCP server. ## Connect it ```console $ lanes link connect navan --profile personal --workspace local ``` Navan registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Navan's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Navan's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'navan.*' --connection navan.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/navan --- # Neon > Connect Neon to your agents with Lanes Link. Postgres projects, branches, and SQL. Postgres projects, branches, SQL, and docs, via Neon's official MCP server. ## Connect it ```console $ lanes link connect neon --profile personal --workspace local ``` Neon registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Neon's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Neon's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'neon.*' --connection neon.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/neon --- # Netlify > Connect Netlify to your agents with Lanes Link. Sites, deploys, functions, and environment variables. Sites, deploys, functions, and environment variables, via Netlify's official MCP server. ## Connect it ```console $ lanes link connect netlify --profile personal --workspace local ``` Netlify registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Netlify's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Netlify's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'netlify.*' --connection netlify.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/netlify --- # Nextcloud Calendar > Connect Nextcloud Calendar to your agents with Lanes Link. Events over CalDAV, on your own server. Read and create events in Nextcloud calendars over CalDAV, on your own server. ## Connect it ```console $ lanes link connect nextcloud_calendar --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `nextcloud_contacts` as well, because they share a credential. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set nextcloud/main --profile personal $ lanes link connect nextcloud_calendar --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do CalDAV gives a fixed set of capabilities, the same on every server that speaks it: - `nextcloud_calendar.list_calendars` - `nextcloud_calendar.list_events` - `nextcloud_calendar.get_event` - `nextcloud_calendar.create_event` - `nextcloud_calendar.update_event` - `nextcloud_calendar.delete_event` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `calendar`, `start`, `end`, `limit`, and `uid` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'nextcloud_calendar.*' --connection nextcloud_calendar.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/nextcloud-calendar --- # Nextcloud Contacts > Connect Nextcloud Contacts to your agents with Lanes Link. Contacts over CardDAV, on your own server. Search and read contacts in a Nextcloud address book over CardDAV, on your own server. ## Connect it ```console $ lanes link connect nextcloud_contacts --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. One password covers `nextcloud_calendar` as well, because they share a credential. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set nextcloud/main --profile personal $ lanes link connect nextcloud_contacts --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do CardDAV gives a fixed set of capabilities, the same on every server that speaks it: - `nextcloud_contacts.list_addressbooks` - `nextcloud_contacts.search_contacts` - `nextcloud_contacts.create_contact` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `addressbook` and `limit` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'nextcloud_contacts.*' --connection nextcloud_contacts.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/nextcloud-contacts --- # Notion > Connect Notion to your agents with Lanes Link. Notion's own MCP server. Pages, databases, comments, and workspace search, via Notion's official MCP server. ## Connect it ```console $ lanes link connect notion --profile personal --workspace local ``` Notion registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Notion's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Notion's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'notion.*' --connection notion.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/notion --- # OneDrive > Connect OneDrive to your agents with Lanes Link. Browse, search, read, and organise files. Browse, search, read, and organise files in OneDrive, via Microsoft Graph. ## Connect it ```console $ lanes link connect onedrive --profile personal --workspace local ``` OneDrive does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan onedrive` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `offline_access` | keep the connection working without sending you to a browser again; Microsoft issues no refresh token without it | | `User.Read` | your name and address, so this connection can be labelled with whose it is. No mail, calendar, or files | | `Files.ReadWrite` | read and write every file in your OneDrive; Microsoft publishes no per-file equivalent of Google's drive.file **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `select`, `drive-id`, `driveItem-id`, `top`, and `orderby` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'onedrive.*' --connection onedrive.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/onedrive --- # Outlook Calendar > Connect Outlook Calendar to your agents with Lanes Link. Read and write events, and answer when you are free. Read and write events in Outlook calendars: list, search, create, reschedule, and cancel, via Microsoft Graph. ## Connect it ```console $ lanes link connect outlook_calendar --profile personal --workspace local ``` Outlook Calendar does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan outlook_calendar` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `offline_access` | keep the connection working without sending you to a browser again; Microsoft issues no refresh token without it | | `User.Read` | your name and address, so this connection can be labelled with whose it is. No mail, calendar, or files | | `Calendars.ReadWrite` | read and write every event in every calendar on the account, including invitations sent to others **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `top`, `orderby`, `select`, `startDateTime`, `endDateTime`, and `event-id` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'outlook_calendar.*' --connection outlook_calendar.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/outlook-calendar --- # Outlook Contacts > Connect Outlook Contacts to your agents with Lanes Link. Look up an address, so 'email Bob' resolves. Read-only. Look up an address in Outlook contacts, so "email Bob" resolves. Read-only. ## Connect it ```console $ lanes link connect outlook_contacts --profile personal --workspace local ``` Outlook Contacts does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan outlook_contacts` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `offline_access` | keep the connection working without sending you to a browser again; Microsoft issues no refresh token without it | | `User.Read` | your name and address, so this connection can be labelled with whose it is. No mail, calendar, or files | | `Contacts.Read` | read the address book. Read-only; nothing here writes a contact | How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `top`, `orderby`, `select`, and `contact-id` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'outlook_contacts.*' --connection outlook_contacts.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/outlook-contacts --- # Outlook Mail > Connect Outlook Mail to your agents with Lanes Link. Read, search, file, and send over Microsoft Graph. Read, search, file, and send mail in an Outlook or Microsoft 365 mailbox, via Microsoft Graph. ## Connect it ```console $ lanes link connect outlook_mail --profile personal --workspace local ``` Outlook Mail does not offer dynamic registration, so this needs an OAuth application of your own: you register it once in their console, and Lanes Link uses those values from then on. Run `lanes link setup plan outlook_mail` for the exact steps, the values it wants, and the command that finishes the job. ## What your agent can do Capabilities are generated from an OpenAPI document vendored into Lanes Link, so the surface is reviewable in a diff and a change upstream cannot widen what your agents can reach without a commit here. ## The scopes it asks for Each provider asks for the narrowest set that makes its capabilities work, because a scope on a consent screen that no tool can spend is a grant asked for and never noticed. | Scope | What it allows | |---|---| | `offline_access` | keep the connection working without sending you to a browser again; Microsoft issues no refresh token without it | | `User.Read` | your name and address, so this connection can be labelled with whose it is. No mail, calendar, or files | | `Mail.ReadWrite` | read, file, and modify any message in the mailbox; no permanent delete **(broad)** | | `Mail.Send` | send mail as you; it leaves the account under your name and cannot be recalled **(broad)** | A scope marked **broad** reaches further than the rest, so `connect` stops and asks for an explicit yes before requesting it. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `top`, `orderby`, `select`, `message-id`, `isRead`, and `categories` are kept. Everything that carries your words is withheld. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'outlook_mail.*' --connection outlook_mail.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/outlook-mail --- # Paddle > Connect Paddle to your agents with Lanes Link. Products, prices, subscriptions, and transactions. Products, prices, subscriptions, and transactions, via Paddle's official MCP server. ## Connect it ```console $ lanes link connect paddle --profile personal --workspace local ``` Paddle registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Paddle's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Paddle's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'paddle.*' --connection paddle.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/paddle --- # PayPal > Connect PayPal to your agents with Lanes Link. Invoices, orders, payments, and disputes. Invoices, orders, payments, subscriptions, and disputes, via PayPal's official MCP server. ## Connect it ```console $ lanes link connect paypal --profile personal --workspace local ``` PayPal registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from PayPal's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are PayPal's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'paypal.*' --connection paypal.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/paypal --- # PostHog > Connect PostHog to your agents with Lanes Link. Events, insights, feature flags, and session replays. Events, insights, feature flags, and session replays, via PostHog's official MCP server. ## Connect it ```console $ lanes link connect posthog --profile personal --workspace local ``` PostHog registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from PostHog's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are PostHog's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'posthog.*' --connection posthog.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/posthog --- # Prisma > Connect Prisma to your agents with Lanes Link. Postgres databases, schema, and migrations. Postgres databases, schema, and migrations, via Prisma's official MCP server. ## Connect it ```console $ lanes link connect prisma --profile personal --workspace local ``` Prisma registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Prisma's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Prisma's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'prisma.*' --connection prisma.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/prisma --- # Ramp > Connect Ramp to your agents with Lanes Link. Cards, transactions, reimbursements, and spend limits. Cards, transactions, reimbursements, and spend limits, via Ramp's official MCP server. ## Connect it ```console $ lanes link connect ramp --profile personal --workspace local ``` Ramp registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Ramp's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Ramp's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'ramp.*' --connection ramp.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/ramp --- # Recurly > Connect Recurly to your agents with Lanes Link. Subscriptions, invoices, and accounts. Subscriptions, invoices, and accounts, via Recurly's official MCP server. ## Connect it ```console $ lanes link connect recurly --profile personal --workspace local ``` Recurly registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Recurly's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Recurly's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'recurly.*' --connection recurly.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/recurly --- # Reddit > Read and search subreddits, and post, comment, and vote as you ```console $ lanes link connect reddit --profile personal --workspace local ``` Reddit is the one account here that needs an app of your own. It takes a couple of minutes, once. ## What your agent can do Reads are granted when you connect. Posting, editing, and voting need the write bundle. | Capability | What it does | |---|---| | `reddit.list_posts` | Posts from one subreddit, in a given order | | `reddit.get_post` | One post with its comment tree | | `reddit.search` | Full-text search within a subreddit | | `reddit.get_subreddit` | Description, subscriber count, and whether posting needs a flair | | `reddit.get_rules` | What a submission has to satisfy | | `reddit.list_flairs` | Flair templates and their ids | | `reddit.whoami` | Which account this connection is | | `reddit.list_my_subreddits` | The subreddits this account subscribes to | | `reddit.submit_post` | Create a text or link post | | `reddit.add_comment` | Comment on a post, or reply to a comment | | `reddit.edit_text` | Replace the body of something this account wrote | | `reddit.vote` | Up, down, or clear | | `reddit.delete_thing` | Permanent. Reddit has no trash reachable from the API | | `reddit.save_thing` | Add to saved items | | `reddit.set_flair` | Apply a flair template to your own post | There is no moderation, no direct messages, and no browsing history. Reddit publishes thirty scopes and this asks for eight, so nothing on the consent screen is a permission no tool can spend. ## Before an agent posts anywhere Writing on Reddit happens publicly, under your username, and cannot be taken back: deleting a post leaves the deletion behind, and anything quoted or replied to in the meantime stays. Two things save most of the trouble: - **Most subreddits require a flair.** `reddit.list_flairs` returns the templates and their ids; pass one as `flair_id`. A submission without one is rejected by the subreddit, not by the API, so the error does not mention flair. - **Read the rules first.** `reddit.get_rules` is cheap, and most removals are rule violations rather than API errors. If you want an agent that reads Reddit but never writes to it, deny the write half: ```console $ lanes link policy deny reddit.submit_post reddit.add_comment reddit.vote --profile personal --workspace local ``` ## Why there is no shared client for this one Google and Slack authorise against a client Lanes operates, so there is no console to visit. Reddit cannot work that way, because of how it meters access: **Reddit's rate limit is a hundred queries a minute per OAuth client id**, not per user and not per token. A shared client would pool every install of this program into one bucket. Your limit would be reached by strangers, and the failure would arrive as somebody else's traffic. An app of your own gets its own hundred, which is the whole budget for one person. ## Registering the app 1. Open [https://www.reddit.com/prefs/apps](https://www.reddit.com/prefs/apps) and choose **create another app...**. 2. Give it a name you will recognise later. The name is how you revoke this one without touching your other apps. 3. Choose **web app**. The other two do not fit: `script` only ever reaches your own account, and `installed app` issues no secret for this to hold. 4. Set the redirect uri to exactly: ``` http://127.0.0.1:8765/callback ``` Reddit matches this character for character. It is the address `connect` listens on, and a different port, or `localhost` in place of `127.0.0.1`, is refused after you approve the consent screen rather than before. 5. Create the app. The **client id** is the string just under the app name; the **secret** is the field labelled `secret`. Then run the connect command above. You are asked for both values once, they go into your credential store, and the browser opens. ## Getting API access approved Creating an app is not the same as being allowed to call the API. Reddit's [Responsible Builder Policy](https://support.reddithelp.com/hc/en-us/articles/42728983564564-Responsible-Builder-Policy) says access must be requested and approved, and the create-app page links to that separately. The quickest way to find out where you stand is to ask for an app-only token: ```console $ curl -sS -X POST -u ":" \ -A "lanes-link/0.3" \ -d "grant_type=client_credentials" \ -w "\nHTTP %{http_code}\n" \ https://www.reddit.com/api/v1/access_token ``` `200` means the gate is open. `403`, or an HTML page instead of JSON, means the app exists but access has not been granted yet. ## When it stops working | What you see | What it is | |---|---| | `invalid redirect_uri` in the browser | The app's redirect uri is not exactly `http://127.0.0.1:8765/callback` | | Connected, then every call fails an hour later | The grant was made without `duration=permanent`. Reconnect with `--replace` | | `403` or HTML from every call | The app exists but API access has not been approved | | `429` | The hundred-per-minute limit for this client id. It is per client, so nothing else is spending it | | `Port 8765 is already in use` | Something else holds the port. The redirect names it exactly, so free the port | If you regenerate the secret: ```console $ lanes link connect reddit --profile personal --workspace local --replace ``` ## The scopes it asks for Reddit publishes thirty scopes. This asks for eight. | Scope | What it allows | |---|---| | `identity` | read your username and signup date | | `read` | read posts and comments | | `submit` | post and comment publicly as you **(broad)** | | `edit` | edit and delete your posts and comments **(broad)** | | `vote` | vote on posts and comments as you **(broad)** | | `save` | save and unsave posts and comments | | `flair` | set the flair on your posts | | `mysubreddits` | list the subreddits you belong to | The three marked **broad** are broad for a different reason from most: not for how far they reach into a private space, but for acting *publicly* under your username. They are the only writes here that cannot be taken back, so `connect` stops and asks for an explicit yes. **Not asked for:** - `privatemessages`. It would put the account's DMs in reach of a tool list whose subject is public posting. - The `mod*` scopes. They act on other people's content in subreddits this account moderates, which is a different job with a different blast radius. How to narrow or widen any of this is on [Scopes and permissions](/docs/link/scopes). ## What is recorded Subreddit, flair id, `nsfw`, and `spoiler` are kept. The body is withheld, **and so is the title**, and so is a link post's `url`. The title looks like metadata and is not. A Reddit title is usually the whole of the post and the body is often empty, so keeping it would defeat withholding the body. See [the audit log](/docs/link/audit). --- **Next:** [every provider](/docs/link/providers), or [Add it to your agent](/docs/link/clients). Source: https://lanes.sh/docs/link/reddit --- # Remote > Connect Remote to your agents with Lanes Link. Employees, contracts, payroll, and time off. Employees, contracts, payroll, and time off, via Remote's official MCP server. ## Connect it ```console $ lanes link connect remote --profile personal --workspace local ``` Remote registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Remote's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Remote's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'remote.*' --connection remote.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/remote --- # Render > Connect Render to your agents with Lanes Link. Services and deploys, takes an API key you paste. Services, deploys, logs, and environment variables, via Render's official MCP server. ## Connect it ```console $ lanes link connect render --profile personal --workspace local ``` This takes a token you generate and paste. Nothing opens a browser, so it also works over SSH and inside a container. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set render/main --profile personal $ lanes link connect render --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do The tool list comes from Render's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Render's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'render.*' --connection render.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/render --- # Replicate > Connect Replicate to your agents with Lanes Link. Models, predictions, and deployments. Models, predictions, and deployments, via Replicate's official MCP server. ## Connect it ```console $ lanes link connect replicate --profile personal --workspace local ``` Replicate registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Replicate's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Replicate's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'replicate.*' --connection replicate.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/replicate --- # Resend > Connect Resend to your agents with Lanes Link. Transactional email, domains, and delivery events. Transactional email, domains, and delivery events, via Resend's official MCP server. ## Connect it ```console $ lanes link connect resend --profile personal --workspace local ``` Resend registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Resend's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Resend's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'resend.*' --connection resend.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/resend --- # Riverside > Connect Riverside to your agents with Lanes Link. Recordings, transcripts, and clips. Recordings, transcripts, and clips, via Riverside's official MCP server. ## Connect it ```console $ lanes link connect riverside --profile personal --workspace local ``` Riverside registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Riverside's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Riverside's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'riverside.*' --connection riverside.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/riverside --- # Rootly > Connect Rootly to your agents with Lanes Link. Incidents, alerts, retrospectives, and on-call schedules. Incidents, alerts, retrospectives, and on-call schedules, via Rootly's official MCP server. ## Connect it ```console $ lanes link connect rootly --profile personal --workspace local ``` Rootly registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Rootly's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Rootly's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'rootly.*' --connection rootly.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/rootly --- # RudderStack > Connect RudderStack to your agents with Lanes Link. Sources, destinations, and event streams. Sources, destinations, and event streams, via RudderStack's official MCP server. ## Connect it ```console $ lanes link connect rudderstack --profile personal --workspace local ``` RudderStack registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from RudderStack's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are RudderStack's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'rudderstack.*' --connection rudderstack.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/rudderstack --- # Salesloft > Connect Salesloft to your agents with Lanes Link. Cadences, people, and sales activity. Cadences, people, and sales activity, via Salesloft's official MCP server. ## Connect it ```console $ lanes link connect salesloft --profile personal --workspace local ``` Salesloft registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Salesloft's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Salesloft's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'salesloft.*' --connection salesloft.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/salesloft --- # Sanity > Connect Sanity to your agents with Lanes Link. Documents, datasets, schema, and content releases. Documents, datasets, schema, and content releases, via Sanity's official MCP server. ## Connect it ```console $ lanes link connect sanity --profile personal --workspace local ``` Sanity registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Sanity's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Sanity's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'sanity.*' --connection sanity.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/sanity --- # Sentry > Connect Sentry to your agents with Lanes Link. Issues, events, stack traces, and releases. Issues, events, stack traces, and releases, via Sentry's official MCP server. ## Connect it ```console $ lanes link connect sentry --profile personal --workspace local ``` Sentry registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Sentry's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Sentry's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'sentry.*' --connection sentry.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/sentry --- # Shortcut > Connect Shortcut to your agents with Lanes Link. Stories, epics, iterations, and workflows. Stories, epics, iterations, and workflows, via Shortcut's official MCP server. ## Connect it ```console $ lanes link connect shortcut --profile personal --workspace local ``` Shortcut registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Shortcut's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Shortcut's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'shortcut.*' --connection shortcut.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/shortcut --- # Slack > Search, read, and send messages, threads, files, and canvases ```console $ lanes link connect slack --profile personal --workspace local ``` A browser opens, you approve, and that is the whole of it. There is no Slack app to create, no scope list to transcribe, and no token to copy. You will see the browser flick through `api.lanes.sh` on the way back. Slack refuses to register a callback that is not HTTPS, and a command line cannot be HTTPS, so it returns you there and that page immediately redirects down to the command waiting on your machine. Nothing is stored on the way through. ## What it will ask for `connect` prints the scopes before the browser opens, and stops if you do not agree to them. These are Slack **user-token** scopes: Slack's MCP server reads the user token, and a bot token is a different credential that does not work here at all. | Scope | What it means | | --- | --- | | `search:read.public`, `search:read.users`, `search:read.files` | Search public channels, people, files | | `search:read.private`, `search:read.im`, `search:read.mpim` | Search private channels and DMs | | `channels:history`, `channels:read` | Read and list public channels | | `groups:history`, `groups:read` | Read and list private channels | | `im:history`, `mpim:history`, `mpim:read` | Read direct and group direct messages | | `users:read` | Read people and profiles | | `files:read` | Read files and their contents | | `chat:write` | Send messages **as you** | | `reactions:write` | Add and remove reactions as you | | `canvases:read`, `canvases:write` | Read and edit canvases | | `channels:write` | Create and manage public channels | Seven of those are flagged broad and need an explicit yes: the four that reach private conversations, the two that search them, and `chat:write`. Slack draws no line between reading a conversation and reading a private one, so a scope that reads like routine access is usually the most sensitive thing in the workspace. Granting a scope is not the same as letting an agent use it. `connect` grants the read bundle and nothing else, and `lanes link policy` is where the rest is turned on. ## If your workspace has not approved the Lanes app An Enterprise Grid admin decides that, and you may not be able to change it. Use a token from an app your workspace already trusts: ```console $ lanes link connect slack --profile personal --workspace local --auth pasted_token ``` 1. Open [https://api.slack.com/apps](https://api.slack.com/apps) and choose **Create New App**, then **From scratch**. Name it and pick the workspace. 2. Open **OAuth & Permissions** and add the scopes you need under **User Token Scopes**, not Bot Token Scopes. The table above is the full set. A smaller set works, and the tools whose scope is missing fail when they are called rather than being hidden. 3. Choose **Install to Workspace** and approve. An admin may have to approve it for you. 4. Copy the **User OAuth Token**. It starts with `xoxp-`. The bot token starts with `xoxb-` and will not work here. The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, the token changes: ```console $ lanes link connect slack --profile personal --workspace local --auth pasted_token --replace ``` Two things are weaker on this path, both recorded in the [security model](/docs/link/security). The stored value is the credential itself rather than a means of obtaining one, so rotating it is manual. And there is no scope-disclosure gate: what the token can do was decided in your console and cannot be read back, so `connect` records what it asked for rather than what it got. ## Why Slack works this way Slack does not support Dynamic Client Registration, so nothing can register itself with Slack the way Notion and Linear do. That is deliberate: registering dynamically would let a client authenticate someone without an app existing, and on Enterprise Grid an admin approves each app first. So a client has to be pre-registered, and the only question is whose. It used to be yours. Now it is one Lanes registered, which is why the browser path exists at all. Lanes holds the app's client secret; the consent is still between you and your workspace, and the token lands in your credential store. Unlike Google, there is no opting out: the hosted client is also the HTTPS address Slack returns you to, so `lanes link connect slack` needs it. Connections already made are unaffected if it is down, because Slack issues no refresh token. ## When it does not work | What you see | What it is | |---|---| | `App is not enabled for Slack MCP server access` | A per-app switch, separate from scopes and from distribution, at `api.slack.com/apps//app-assistant`. Only the owner of the app sees this, so on the browser path it is already on. Re-run `connect` afterwards | | `Slack refused the token` | A bot token (`xoxb-`) where the user token (`xoxp-`) belongs, a scope missing from User Token Scopes, or an app reinstalled since (which mints a new token) | | A tool is listed and fails when called | Its scope was not granted. Re-run `connect` to consent again, or add the scope in your own app | ## What is recorded Every call is recorded, allowed or refused. Slack's MCP server is Slack's, so the capabilities are discovered at connect time rather than declared here, and redaction has no authored list to key on: the default withholds every argument value. You get the call, not what was in it. That is a thinner record than Gmail gives, and it is the honest consequence of proxying somebody else's tool list. See [the audit log](/docs/link/audit). --- **Next:** [every provider](/docs/link/providers), or [Add it to your agent](/docs/link/clients). Source: https://lanes.sh/docs/link/slack --- # Square > Connect Square to your agents with Lanes Link. Payments, orders, catalog, inventory, and customers. Payments, orders, catalog, inventory, and customers, via Square's official MCP server. ## Connect it ```console $ lanes link connect square --profile personal --workspace local ``` Square registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Square's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Square's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'square.*' --connection square.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/square --- # Storyblok > Connect Storyblok to your agents with Lanes Link. Stories, components, assets, and spaces. Stories, components, assets, and spaces, via Storyblok's official MCP server. ## Connect it ```console $ lanes link connect storyblok --profile personal --workspace local ``` Storyblok registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Storyblok's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Storyblok's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'storyblok.*' --connection storyblok.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/storyblok --- # Stripe > Connect Stripe to your agents with Lanes Link. Payments, customers, invoices, and subscriptions. Payments, customers, invoices, subscriptions, and balances, via Stripe's official MCP server. ## Connect it ```console $ lanes link connect stripe --profile personal --workspace local ``` Stripe registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Stripe's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Stripe's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'stripe.*' --connection stripe.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/stripe --- # Supabase > Connect Supabase to your agents with Lanes Link. Projects, database schema, SQL, and edge functions. Projects, database schema, SQL, edge functions, and docs, via Supabase's official MCP server. ## Connect it ```console $ lanes link connect supabase --profile personal --workspace local ``` Supabase registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Supabase's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Supabase's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'supabase.*' --connection supabase.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/supabase --- # Tavily > Connect Tavily to your agents with Lanes Link. Web search and page content extraction. Web search and page content extraction, via Tavily's official MCP server. ## Connect it ```console $ lanes link connect tavily --profile personal --workspace local ``` Tavily registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Tavily's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Tavily's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'tavily.*' --connection tavily.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/tavily --- # Todoist > Connect Todoist to your agents with Lanes Link. Tasks, projects, sections, labels, and filters. Tasks, projects, sections, labels, and filters, via Todoist's official MCP server. ## Connect it ```console $ lanes link connect todoist --profile personal --workspace local ``` Todoist registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Todoist's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Todoist's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'todoist.*' --connection todoist.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/todoist --- # Vercel > Connect Vercel to your agents with Lanes Link. Projects, deployments, build logs, and domains. Projects, deployments, build logs, and domains, via Vercel's official MCP server. ## Connect it ```console $ lanes link connect vercel --profile personal --workspace local ``` Vercel registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Vercel's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Vercel's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'vercel.*' --connection vercel.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/vercel --- # Vimeo > Connect Vimeo to your agents with Lanes Link. Videos, folders, showcases, and analytics. Videos, folders, showcases, and analytics, via Vimeo's official MCP server. ## Connect it ```console $ lanes link connect vimeo --profile personal --workspace local ``` Vimeo registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Vimeo's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Vimeo's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'vimeo.*' --connection vimeo.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/vimeo --- # Webflow > Connect Webflow to your agents with Lanes Link. Sites, pages, CMS collections, and items. Sites, pages, CMS collections, and items, via Webflow's official MCP server. ## Connect it ```console $ lanes link connect webflow --profile personal --workspace local ``` Webflow registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Webflow's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Webflow's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'webflow.*' --connection webflow.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/webflow --- # Whimsical > Connect Whimsical to your agents with Lanes Link. Boards, flowcharts, wireframes, and mind maps. Boards, flowcharts, wireframes, and mind maps, via Whimsical's official MCP server. ## Connect it ```console $ lanes link connect whimsical --profile personal --workspace local ``` Whimsical registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Whimsical's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Whimsical's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'whimsical.*' --connection whimsical.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/whimsical --- # Wix > Connect Wix to your agents with Lanes Link. Sites, stores, bookings, and CMS data. Sites, stores, bookings, and CMS data, via Wix's official MCP server. ## Connect it ```console $ lanes link connect wix --profile personal --workspace local ``` Wix registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Wix's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Wix's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'wix.*' --connection wix.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/wix --- # Workable > Connect Workable to your agents with Lanes Link. Jobs, candidates, and interviews. Jobs, candidates, and interviews, via Workable's official MCP server. ## Connect it ```console $ lanes link connect workable --profile personal --workspace local ``` Workable registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Workable's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Workable's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'workable.*' --connection workable.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/workable --- # Yahoo Mail > Connect Yahoo Mail to your agents with Lanes Link. Read, search, and send over IMAP and SMTP. Read, search, and send mail in a Yahoo mailbox over IMAP and SMTP, with an app password. ## Connect it ```console $ lanes link connect yahoo_mail --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set yahoo_mail/main --profile personal $ lanes link connect yahoo_mail --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do IMAP and SMTP give a fixed set of capabilities, the same on every mailbox: - `yahoo_mail.list_mailboxes` - `yahoo_mail.search_messages` - `yahoo_mail.get_message` - `yahoo_mail.get_attachment` - `yahoo_mail.mark_messages` - `yahoo_mail.move_messages` - `yahoo_mail.send_message` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `mailbox`, `limit`, `unseen`, `flagged`, `uid`, and `message_id` are kept. Everything that carries your words is withheld. `yahoo_mail.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'yahoo_mail.*' --connection yahoo_mail.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/yahoo-mail --- # Zapier > Connect Zapier to your agents with Lanes Link. Zaps, and the actions they reach across thousands of apps. Zaps, and the actions they reach across thousands of apps, via Zapier's official MCP server. ## Connect it ```console $ lanes link connect zapier --profile personal --workspace local ``` Zapier registers Lanes Link at the moment you run this, so there is no OAuth client to create and no console to visit. A browser opens, you approve, and it is live. ## What your agent can do The tool list comes from Zapier's own MCP server and is discovered when you connect, so the capabilities are theirs rather than ours. To see exactly what your agents are handed right now: ```console $ lanes link tools --profile personal --workspace local ``` ## What is recorded Every call is recorded, allowed or refused. Because the capabilities are Zapier's rather than ours, redaction has no authored list to key on, and the default withholds every argument value: you get the call, not what was in it. That is the right default when we did not write the capability and cannot know what is sensitive. It does mean this connection gives you a thinner record than a provider with a vendored specification does. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'zapier.*' --connection zapier.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/zapier --- # Zoho Mail > Connect Zoho Mail to your agents with Lanes Link. Read, search, and send over IMAP and SMTP. Read, search, and send mail in a Zoho mailbox over IMAP and SMTP, with an application-specific password. ## Connect it ```console $ lanes link connect zoho_mail --profile personal --workspace local ``` This takes an app password rather than a browser sign-in. Because it needs no browser, an agent with a shell can do the whole setup: ```console $ printf %s "$TOKEN" | lanes link secrets set zoho_mail/main --profile personal $ lanes link connect zoho_mail --id main --non-interactive --json --profile personal ``` Credentials go in on stdin, never as a flag: an argument lands in your shell history, in `ps` output, and in any transcript. ## What your agent can do IMAP and SMTP give a fixed set of capabilities, the same on every mailbox: - `zoho_mail.list_mailboxes` - `zoho_mail.search_messages` - `zoho_mail.get_message` - `zoho_mail.get_attachment` - `zoho_mail.mark_messages` - `zoho_mail.move_messages` - `zoho_mail.send_message` ## What is recorded Every call is recorded, allowed or refused. The rule is to keep the shape and withhold the words: an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. Identifiers such as `mailbox`, `limit`, `unseen`, `flagged`, `uid`, and `message_id` are kept. Everything that carries your words is withheld. `zoho_mail.send_message` records nothing but the receipt. What an entry holds and how to read one is on [the audit log](/docs/link/audit). ## Narrowing what it may do Connecting grants the read bundle. Tightening it happens on your machine and takes effect at once: ```console $ lanes link policy deny 'zoho_mail.*' --connection zoho_mail.main --profile personal --workspace local ``` A deny always beats an allow, whatever the order in the file. See [Scopes and permissions](/docs/link/scopes) for widening, and for what can and cannot be set per connection. ## Untested The manifest is right in every way the code can check: it validates, its tools generate inside the budget, its scopes are described, and its endpoint answered a probe. Nobody has yet connected it to a real account, which is the part only a real account proves. If you connect it, [say so](https://github.com/lanes-sh/link/issues) and this note comes off. --- **Next:** [every provider](/docs/link/providers), or [Connections](/docs/link/connect) for what a connection is and the methods behind one. Source: https://lanes.sh/docs/link/zoho-mail --- # Commands > Every command, its arguments, and its flags Every command, its arguments, and its flags. `lanes link help` is the same list without the detail; [`workflow.md`](/docs/link/workflow) walks the lifecycle in order. **Every command names what it acts on.** `--profile ` and `--workspace `, with no default and no environment variable ([ADR-037](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/037-a-command-names-what-it-acts-on.md)). They are omitted from the tables below because they apply to almost everything; each entry says when a command wants only `--profile`, or neither. **`--json`, `--quiet` and `--help` are accepted everywhere.** Any other flag a command does not read is refused, with a guess when the spelling is close. **Values arrive on stdin.** `memory write`, `skills add`, `vault set` and `secrets set` read from a pipe and refuse rather than hang when stdin is a terminal. `tasks add` is the exception: its notes are optional, so an empty pipe or a terminal simply means a task with no notes. > Three commands cannot be run at all, several documented flags are refused when you type them, and > a few more are accepted and ignored. Each is marked **broken** in its own entry, and the tables > carry only what works. [#44](https://github.com/lanes-sh/link/issues/44) has the detail. --- ## Everyday ### `lanes link setup plan []` What connecting something involves: with no argument, every provider and whether it is connected; with one, the console steps, the values, and the command to run. | | | |---|---| | `--id ` | plan for a specific connection id | ```console $ lanes link setup plan --profile personal --workspace local $ lanes link setup plan gmail --profile personal --workspace local ``` ### `lanes link connect [.]` Add an account. Run once per account; a second run adds a second account. `.` re-authorises an existing one. It ends by asking what to call the connection, offering the account it just resolved: ```console What should this be called? [ada@example.com]: ``` Pressing Enter takes the account, which is the usual answer. Anything else is written to `label` and changes nothing else. See `relabel` below for why that is a separate field from `account`. The question is skipped when `--label` says it, when there is no terminal to ask, and for the second and third service of a family: `connect icloud` is one account, so it is named once rather than three times. | | | |---|---| | `--id ` | the connection id, instead of one derived from the account | | `--display-name ` | whose account this is, for a provider that cannot report it | | `--label ` | what to call the connection, instead of being asked | | `--replace` | ask for the stored password or key again | | `--auth ` | which way in, where a provider offers two; `oauth` is the browser | | `--own-client` | your own OAuth client rather than this project's (older spelling of an `--auth` route) | | `--accept-broad-scopes` | agree in advance to scopes broader than the provider needs | | `--non-interactive` | take every value from the credential store, or say what is missing | ```console $ lanes link connect gmail --profile personal --workspace local $ lanes link connect gmail --profile personal --workspace local --id work ``` `--workspace` never points at a running endpoint: `connect --workspace cloud` runs consent locally and writes the token into the deployment's store. ### `lanes link disconnect .` Remove one account, and delete the credential it authorised. The counterpart to `connect`, and the two edits it takes: the entry leaves `connections:` in the profile, and the credential leaves the workspace's store. The key must be exact. `connect gmail` can create an account and choose the id; there is nothing to choose here, and a bare `gmail` with two accounts declared would be a command guessing which one to throw away, so it refuses and names them. ```console $ lanes link disconnect gmail.side --profile personal --workspace local $ lanes link disconnect gmail.side --profile personal --workspace local --yes --json $ lanes link disconnect gmail.side --profile personal --workspace local --keep-credential ``` **Three things it deliberately does not do.** It does not delete the state record. Reconcile marks an undeclared connection `disabled` rather than deleting it so the audit log keeps meaning something, and reaching past that to erase the record would undo the one guarantee the log offers. The next reconcile marks it, and the command says so. It does not delete a credential something else still resolves to. A reference is per connection for an OAuth provider (`/`), and *shared* for a manifest declaring `credential_ref`, deleting that one while a sibling resolves to it would take the sibling's credential with it, and the sibling would then report `unauthorized` for a `connect` nobody ran. When that happens the credential is kept and the output names what still needs it. It does not touch the owner layer. `memory`, `skills`, `vault`, `setup` and `identity` hold no credential and are granted by a policy line this command does not touch, so removing the connection alone would leave the policy granting against nothing, which is wrong rather than merely untidy. It refuses and points at the file, where both lines are next to each other. ### `lanes link relabel . ` Rename what an account is called. The label is what `status` and any surface built on it shows; it is not an identifier, so nothing addresses the connection by it and changing one breaks nothing. It writes `label`, never `account`, and the difference is not cosmetic. `account` is the identity the provider reported, and three things read it as one: `settleIdentity` matches on it to decide a second `connect` is a repair rather than a new account, the connection id is derived from it, and `gmail.send_message` writes it into a `From` header. Renaming through `account`, which this command did until the field existed, left a row the next `connect gmail` no longer recognised, and added a second one beside it. ```console $ lanes link relabel gmail.main "Work mail" --profile personal --workspace local $ lanes link relabel gmail.main Work mail --profile personal --workspace local ``` Both forms work: an unquoted multi-word name is joined, because quoting is what someone remembers second and refusing it teaches nothing. `connect` already asks for one and offers the account as the answer, so this is for changing your mind later, and for the surfaces that drive the CLI rather than type it. Allowed for the owner layer, unlike `disconnect`: a display name is harmless, and "Memory" is the operator's word for their own store. The state store keeps the old name until the next reconcile, which updates it. ### `lanes link connect custom ` Declare a service that is not built in, and connect it. The provider is composed from two fixed lists, one connectivity type and one credential type, written to `data//providers.d/.yaml`, and then connected exactly as a built-in is. The manifest is yours to edit afterwards; `lanes link connect ` re-reads it every time. Omit a required value and it is asked for. `--non-interactive` names every missing one at once instead, with the command to re-run. | | | |---|---| | `--connector ` | `mcp`, `http`, `imap`, `dav`, `fs` | | `--auth ` | `none`, `bearer`, `api-key`, `header`, `basic`, `oauth`, `strategy` | | `--name `, `--description ` | how the provider is labelled; the name defaults to the id read as words | | `--endpoint ` | `mcp`: the URL the server speaks Streamable HTTP on | | `--base-url ` | `http` and `dav` | | `--openapi ` | `http`: a URL, or a path resolved against the manifest's own directory | | `--operations ` | `http`: which operations to expose, by operationId, path or tag | | `--service ` | `dav`: `caldav` or `carddav` | | `--host`, `--port` | `imap` | | `--smtp-host`, `--smtp-port` | `imap`: omit the host and the mailbox is read-only, with no send capability | | `--root `, `--exclude ` | `fs` | | `--header 'Name: value'` | sent on every request, repeatable; `mcp` and `http` only. Never `Authorization` | | `--auth-header ` | the header the credential is sent in | | `--auth-query ` | `api-key` only: the query parameter instead of a header | | `--scopes ` | `oauth` | | `--authorize-url`, `--token-url` | `oauth`: together or not at all; required on `http` | | `--client-app ` | which `oauth_apps` entry holds the client; defaults to the id | | `--registration ` | `dynamic` or `manual`; `mcp` defaults to `dynamic`, everything else to `manual` | | `--authorize-param k=v` | `oauth`: extra parameters on the authorization request, repeatable. Some vendors issue no refresh token without one | | `--redirect-uri ` | only for a vendor that matches the whole redirect URL (ADR-045) | | `--strategy ` | `strategy`: the name a provider in this build supplies, e.g. `bunq`. `http` only | | `--strategy-option k=v` | `strategy`: repeatable, read by the strategy itself, for a sandbox host, say | | `--identity-url`, `--identity-field` | `http`: one GET that names the account, and which field to read | | `--setup-docs ` | where the credential comes from. A URL becomes a link, a sentence a step | | `--replace-manifest` | rewrite a declaration that exists and differs. `--replace` is about the credential | It also takes `--id`, `--display-name`, `--label`, `--replace`, `--accept-broad-scopes` and `--non-interactive`, which are forwarded to the connect that follows. ```console $ lanes link connect custom docs_server --connector mcp --auth oauth \ --endpoint https://mcp.example.com/mcp --profile personal --workspace local $ lanes link connect custom thing --connector http --auth api-key --auth-header X-Api-Key \ --base-url https://api.example.com/v1 --openapi https://api.example.com/openapi.json \ --profile personal --workspace local $ lanes link connect custom mailbox --connector imap --auth basic \ --host imap.example.com --smtp-host smtp.example.com --smtp-port 465 \ --profile personal --workspace local ``` `--auth strategy` names code a provider in this build carries, which a manifest of your own can borrow, and the only way to point a connection at a vendor's sandbox, since a built-in manifest's `options` are not yours to edit ([ADR-046](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/046-an-auth-strategy-belongs-to-its-provider.md)). Thirteen of the thirty-five possible pairs are legal, and the rest are refused with the alternative named, an `mcp` connector has nowhere to put an API key, an `imap` one authenticates with a password, an `fs` one holds no account at all. [`connectivity-coverage.md`](/docs/link/connectivity-coverage) is the whole matrix, including what no pair covers yet. No flag carries a credential. The manifest declares what to ask for and the ordinary connect path asks, for the same reason `secrets set` reads stdin rather than an argument. Two limits worth knowing. A declaration is written to the local filesystem, so this refuses a workspace that is a bucket: declare it where you deploy from, and `lanes link deploy` carries it up. And a failed connect leaves the manifest in place. That is the normal state of a hand-written one, `status` reports it, and the retry is plain `lanes link connect `. ### `lanes link start` Reconcile, then serve every profile in the workspace on one endpoint. | | | |---|---| | `--port ` | override the configured port | | `--only` | serve the resolved profile alone | ```console $ lanes link start --profile personal --workspace local ``` ### `lanes link outputs` The endpoint URL, whether it is answering, the profiles behind it, the token, and a registration command to adapt. | | | |---|---| | `--show` | reveal the token | ```console $ lanes link outputs --profile personal --workspace local --show ``` ### `lanes link pair` Let the [Lanes dashboard](https://lanes.sh/dashboard/link) read this workspace: its connections, its profiles, and its audit log. It mints a credential that reads all three and can change nothing, then prints a link carrying it. The token travels in the URL fragment, which is never sent to a server — so it does not reach a Lanes access log, a proxy, or a referrer header on the way to a page whose whole point is that Lanes does not see this data. The page fetches your endpoint directly. | | | |---|---| | `--print` | print the link for an existing pairing and change nothing | | `--rotate` | mint a fresh token, so whatever a browser is holding stops working | | `--yes` | do not ask before installing mkcert | **On this machine**, a browser will not fetch `http://127.0.0.1` from an `https://` page — Safari refuses it outright and offers no way to allow it — so pairing installs a locally trusted certificate with [mkcert](https://github.com/FiloSottile/mkcert) and serves a second, read-only listener one port above the endpoint. That certificate goes into your system trust store, which is the largest thing any command here does, so it asks first and stops if you decline. ```console $ lanes link pair --workspace local ok certificate installed ok the dashboard may now read https://127.0.0.1:7338 https://lanes.sh/dashboard/link#pair=llp_...&at=https%3A%2F%2F127.0.0.1%3A7338 ``` **The address rides in the link, not just the token.** The read listener sits one port above `instance.port`, so an endpoint on anything but the default is on a port the dashboard has no way to guess — and a page that guessed wrong reported it unreachable while it was answering. The endpoint has to be running for that link to read anything: `lanes link start` and `lanes link pair` are two commands, and pairing starts nothing. **On a deployed workspace** there is no certificate to install — the platform terminates TLS with one browsers already trust — so nothing is installed and nothing is asked. The same two routes answer on the endpoint's own address, which is what the link carries in place of a loopback one ([ADR-064](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/064-a-deployed-endpoint-is-read-over-its-own-url.md)). ```console $ lanes link pair --workspace cloud ok no certificate needed — this endpoint already has one a browser trusts ok the dashboard may now read https://.run.app ``` A rotation on a deployed workspace takes up to five seconds to be refused, because the endpoint caches what it read rather than calling Secret Manager on every request. The dashboard reads one endpoint at a time and can hold several pairings at once, so a laptop and a deployment both appear in the switcher and you pick which one you are looking at. ### `lanes link desktop` Open the Lanes desktop app on its Lanes Link page, at Settings → Integrations → Lanes Link, which drives this CLI from a window instead of a prompt. `lanes link dashboard` is the same command under its older name, from when it opened a page the endpoint served ([ADR-053](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/053-the-page-a-person-reads-is-the-app.md)). **If the app is not there, it offers to install it** with `brew install --cask lanes-sh/lanes/lanes`, then opens it. It asks first: this is the only command here that puts an application on the machine rather than a file in your own workspace. `--yes` answers ahead of time, and a run with no terminal is refused rather than assumed. macOS only, because the app is. Elsewhere it says so and prints the link it would have opened. | | | |---|---| | `--print` | print the link instead of opening it | | `--yes` | install the app without asking, if it is missing | It takes neither `--profile` nor `--workspace`, and refuses them: it resolves nothing, and the app holds its own selection. If you have `lanes link dashboard --profile … --workspace …` in a script, drop the flags. ```console $ lanes link desktop ok opened Lanes → Settings → Integrations → Lanes Link needs Lanes 0.48.0 or newer; an older app ignores the link. $ lanes link desktop --print lanes://settings?page=integrations-link ``` The first run on a machine without it: ```console $ lanes link desktop warn the Lanes app is not installed brew install --cask lanes-sh/lanes/lanes Install it now? [Y/n] installing, which takes a minute the first time… ok installed Lanes ok opened Lanes → Settings → Integrations → Lanes Link ``` Without Homebrew it cannot finish the job, so it hands over both routes: the line above, and `https://lanes.sh/desktop` to download the app directly. ### `lanes link mcp add [claude|codex]` Register this endpoint with an agent, and install the documents that describe it. With no argument, every harness found. | | | |---|---| | `--name ` | register under a name other than `lanes-link` | | `--scope ` | the harness's own scope; `user` by default | | `--token-env ` | the variable a harness reads its token from; `LANES_LINK_TOKEN` by default | | `--no-skill` | register only, leaving the agent's files alone | | `--force` | replace a registration this name already has | | `--dry-run` | print each command it would run, token redacted | ```console $ lanes link mcp add --profile personal --workspace local $ lanes link mcp add codex --profile personal --workspace local --no-skill ``` ### `lanes link mcp list` Where this endpoint is registered, and whether each harness's copy of the documents is current. Needs no profile and no workspace. | | | |---|---| | `--name ` | look under a different registration | | `--scope ` | look in a different scope | ```console $ lanes link mcp list ``` ### `lanes link mcp stdio` Serve on stdin and stdout, for a client that spawns the command rather than calling a URL. Not typed by hand: it is what a config file names. | | | |---|---| | `--only` | serve the resolved profile alone | ```console $ lanes link mcp stdio --profile personal --workspace local ``` ### `lanes link mcp skill` The bundled agent skill: its path, or the document itself. | | | |---|---| | `--print` | write the document to stdout | | ~~`--force`~~ | **broken**, accepted and ignored | ```console $ lanes link mcp skill --print ``` ### `lanes link status` Names a `--workspace`. With no `--profile` it reports the whole workspace: every profile, whether it declares it, and how many connections each has, which is how a workspace declared by one profile and not its sibling becomes visible. With `--profile` it is the detailed view: connections, the capabilities reachable through them, and where the endpoint is. No network call and no store opened either way, so it still answers for a workspace whose stores are unreachable, which is the case you most want an answer in. ```console $ lanes link status --workspace cloud # every profile $ lanes link status --profile personal --workspace local # one, in detail ``` --- ## Profiles ### `lanes link profile add ` Write a new profile, and the workspace file if it is the first. Names no profile and no workspace of its own. | | | |---|---| | `--workspace ` | a workspace per place it runs, repeatable; `local` is derived, others copied from a sibling | | `--non-interactive` | never prompt | ```console $ lanes link profile add personal --workspace local ``` ### `lanes link profile list` Every profile in the workspace and its path. Needs no profile and no workspace. ```console $ lanes link profile list ``` ### `lanes link profile remove ` The profile, its credentials, its data, and its config file. Best effort: one refusal does not stop the rest, and anything that survived exits non-zero. The confirmation asks you to type the name. | | | |---|---| | `--dry-run` | print the plan, write to nothing | | `--yes` | skip the confirmation | | `--workspace ` | decommission one workspace's stores, leaving the profile file in place | ```console $ lanes link profile remove work --dry-run ``` The positional name is what gets removed. `--profile` is refused, as it is on `profile add`: both name their profile positionally, so a flag naming a second one could only disagree with it. ### `lanes link profile default ` **Removed.** It wrote `default_profile`, which nothing reads. Kept as a refusal for one release. ```console $ lanes link profile default work error lanes link profile default was removed. ``` --- ## Targets A workspace holds connections and profiles, and names where they live: a credential store, a blob store, optionally a deployment. Everything above them is declared once and applies to all of them. ### `lanes link workspace list` What this profile declares, and which one you named. Reads the file and asks nobody. Requires no workspace. It is the command you run to find out what to pass. | | | |---|---| | `--urls` | add each deployable workspace's address, one platform lookup apiece | | `--workspace ` | mark one, and warn if it is not declared | ```console $ lanes link workspace list --profile personal ``` ### `lanes link workspace show ` One workspace's adapters, its deployment, the address it answers on, and which release rolled it. Asks the platform for the address; the release comes off the registry, so it answers with the service scaled to zero. | | | |---|---| | `--workspace ` | the workspace, if not given positionally | ```console $ lanes link workspace show local --profile personal ``` ### `lanes link target use ` **Removed.** It wrote `instance.default_target`, which nothing read. Pass `--workspace`, or write a default with `lanes set-workspace `, which every command that uses it echoes and every command that publishes or destroys refuses. ```console $ lanes link target use cloud error lanes link target use was removed. ``` --- ## Who you are ### `lanes link identity add ` Declare one entry. `kind` is yours to choose; `name`, `email`, `github` are conventions. Order is the ranking. The first `add` also writes the connection row and the `identity.*` grant. | | | |---|---| | `--note ` | when this entry applies, read by whatever picks between several of a kind | ```console $ lanes link identity add name "A. Lovelace" --note "for anything published" \ --profile personal --workspace local ``` ### `lanes link identity list` The block, and whether anything but the file can read it. Takes `--profile` only. ```console $ lanes link identity list --profile personal ``` ### `lanes link identity remove ` Drop one entry, leaving the connection row and the grant in place. ```console $ lanes link identity remove name Ada --profile personal --workspace local ``` --- ## Permissions Default deny. A deny beats any allow whatever the order in the file, and the only operator beyond an exact name is a trailing `.*`. ### `lanes link policy list` The rules in force. Takes `--profile` only. ```console $ lanes link policy list --profile personal ``` ### `lanes link policy allow ` Grant a capability, or a provider with `gmail.*`. A rule naming an unknown connection is refused at write time. Widening a *vendor* scope needs browser re-consent and goes through `connect`. ```console $ lanes link policy allow gmail.send_message --profile personal --workspace local ``` ### `lanes link policy deny ` Refuse a capability, whatever else allows it. ```console $ lanes link policy deny skills.manage.* --profile personal --workspace local ``` ### `lanes link token show` The one bearer token this endpoint accepts, truncated unless asked otherwise. | | | |---|---| | `--show` | reveal it | | `--raw` | print only the token, no resolution line, for `$(…)` | ```console $ lanes link token show --raw --profile personal --workspace local ``` ### `lanes link token rotate` Mint a new token; invalidates the old immediately. Every agent must be registered again, and a harness stores the token it was given, not the command that produced it. | | | |---|---| | `--show` | reveal the replacement | | ~~`--raw`~~ ~~`--yes`~~ | **broken**, accepted and ignored | ```console $ lanes link token rotate --profile personal --workspace local ``` --- ## Your own context Memory, tasks, assets, skills, the vault and entities are providers holding your material rather than an account, so a profile arrives with all six declared and granted. There is nothing to connect ([ADR-050](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/050-the-owner-layer-is-granted-by-default.md)). Each also has a CLI of its own, which reaches the same bytes an agent does. Which store a thing goes in: **memory is what is true, tasks is what is to be done, assets is a file, entities is who somebody is.** Nothing refuses the wrong choice, so it is worth knowing: [ADR-051](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/051-tasks-and-assets-are-their-own-stores.md) has the reasoning. `--connection ` picks between several of a kind; with exactly one it is inferred. ### `lanes link memory list` Ids, titles, tags, and the date each was last written. | | | |---|---| | `--connection ` | which memory connection | | `--tag ` | only memories carrying this tag | | ~~`--raw`~~ | **broken**, refused | ```console $ lanes link memory list --profile personal --workspace local ``` ### `lanes link memory get ` One entry's body, as stored. ```console $ lanes link memory get deploy-window --profile personal --workspace local ``` ### `lanes link memory write ` Create or replace one entry; body on stdin. An entry is one Markdown file with YAML frontmatter, so a text editor is an equally good client. | | | |---|---| | `--title ` | what a listing shows; a rewrite without one keeps the existing title | | `--connection ` | which memory connection | | ~~`--tag `~~ | **broken**, refused, though the help text lists it | ```console $ printf 'The deploy window is Thursday evening.' \ | lanes link memory write deploy-window --title "Deploy window" \ --profile personal --workspace local ``` ### `lanes link memory forget ` Delete one entry, after printing it and asking. > **Broken in a script.** With no terminal it says "Pass `--yes` to proceed", and `--yes` is > refused. Interactive use is unaffected. ```console $ lanes link memory forget deploy-window --profile personal --workspace local ``` ### `lanes link tasks list` What is outstanding: id, status, title, due date, tags. Shows `in_progress`, `open` and `blocked` and hides the rest, because the question is almost always what is left to do. | | | |---|---| | `--connection ` | which tasks connection | | `--status ` | one status, or `all` for everything | | `--tag ` | only tasks carrying this tag | ```console $ lanes link tasks list --profile personal --workspace local Tasks (2 outstanding) write-the-release-notes in_progress write the release notes chase-the-invoice open chase the invoice due 2026-09-01 billing ``` ### `lanes link tasks get ` One task: title, status, due date, tags, then the notes. ```console $ lanes link tasks get chase-the-invoice --profile personal --workspace local ``` ### `lanes link tasks add ` Record something to be done. The title is the argument, because a task is usually one line; notes are optional and read from stdin when there are any. A task is one Markdown file with YAML frontmatter, so a text editor is an equally good client. | | | |---|---| | `--status <status>` | `in_progress`, `open`, `blocked`, `muted`, `done`, `dropped`. Defaults to `open`. | | `--due <when>` | as you would write it: `2026-09-01`, or a full instant. Kept verbatim. | | `--tag <tag>` | one label | ```console $ lanes link tasks add "chase the invoice" --due 2026-09-01 --profile personal --workspace local ok added task chase-the-invoice $ printf 'Third reminder.' | lanes link tasks add "chase the invoice" --profile personal --workspace local ``` The id is derived from the title. Adding under an id that exists replaces it, keeping the date it was first recorded. ### `lanes link tasks update <id>` Change a task in place. **This is how a task is closed**, and the record of having done it is the useful half, and it is what stops the same thing being suggested next week. Omitted flags leave their fields alone. | | | |---|---| | `--status <status>` | the new status | | `--title <t>` | replaces the title | | `--due <when>` | replaces the due date; `--due ""` clears it | | `--tag <tag>` | replaces the tags | ```console $ lanes link tasks update chase-the-invoice --status done --profile personal --workspace local ok updated task chase-the-invoice — now done ``` ### `lanes link tasks remove <id>` Delete a task, after printing it and asking. For something finished or decided against, prefer `update --status done` or `--status dropped`: deleting loses the record that it happened. | | | |---|---| | `--yes` | skip the confirmation | ```console $ lanes link tasks remove mistake --yes --profile personal --workspace local ``` ### `lanes link assets list` Every file kept in this profile, newest first, with its type and size. This is the whole index, and an asset carries no description, so what a file is *for* belongs in memory beside its name. ```console $ lanes link assets list --profile personal --workspace local Assets (2) blob.bin application/octet-stream 3 KB 2026-08-27 report.csv text/csv 12 B 2026-08-27 ``` ### `lanes link assets get <name>` The bytes, to stdout. **Redirect them.** It refuses a terminal rather than writing a binary into your scrollback. ```console $ lanes link assets get invoice.pdf --profile personal --workspace local > invoice.pdf ``` This is the one place the CLI and the capability deliberately differ: `assets.get` over MCP describes a binary rather than returning it, because a conversation has a context window and a shell does not. ### `lanes link assets add <file>` Keep a file. A path on this machine; the other four sources the capability takes exist because the endpoint may not be where the caller is, and a CLI already is. | | | |---|---| | `--name <n>` | what to call it, where the path's basename is wrong | | `--content-type <t>` | for the file whose extension does not say what it is | ```console $ lanes link assets add ~/Downloads/invoice.pdf --profile personal --workspace local ok kept invoice.pdf — 184 KB, sha256 9284ed4fd7fe… ``` Storing under a name that exists replaces it. A name may not contain a path separator, start with a dot, or end `.meta` or `.tmp`. The last two are suffixes a blob store keeps its own bookkeeping under and would hide the file. ### `lanes link assets remove <name>` Delete a file, after printing it and asking. There is no trash. | | | |---|---| | `--yes` | skip the confirmation | ```console $ lanes link assets remove invoice.pdf --yes --profile personal --workspace local ``` ### `lanes link skills list` The procedures agents can invoke, and each description. | | | |---|---| | `--connection <id>` | which skills connection | | ~~`--raw`~~ | **broken**, refused | ```console $ lanes link skills list --profile personal --workspace local ``` ### `lanes link skills show <name>` One skill exactly as stored, frontmatter included. ```console $ lanes link skills show review-diff --profile personal --workspace local ``` ### `lanes link skills add <name>` Create or replace one skill. A whole Markdown document: YAML frontmatter carrying a `description`, then the body, where `{{argument}}` is substituted at invocation. **The `---` fence is required.** | | | |---|---| | `--file <path>` | read the document from a file rather than stdin | | `--connection <id>` | which skills connection | ```console $ lanes link skills add review-diff --file review-diff.md --profile personal --workspace local ``` Becomes the MCP prompt `skills_<name>`; a running endpoint picks it up within seconds. ### `lanes link skills remove <name>` Delete one skill, after asking. > **Broken in a script**, same as `memory forget`: it asks for `--yes`, and refuses `--yes`. ```console $ lanes link skills remove review-diff --profile personal --workspace local ``` ### `lanes link knowledge show` Where this profile's memory and skills are kept, and how many of each. ```console $ lanes link knowledge show --profile personal --workspace local ``` ### `lanes link knowledge use github|local` Keep memory and skills in a private GitHub repository, over the API, or bring them back onto the workspace's own storage. The block is written into every workspace the profile declares; each still needs the token in its own store, which is what `secrets push` is for ([ADR-041](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/041-memory-and-skills-in-a-repository.md)). | | | |---|---| | `--repo <owner/name>` | the repository | | `--branch <name>` | the branch | | `--path <prefix>` | a subdirectory rather than the root | | `--migrate` | move what is already stored, in one commit | | `--no-migrate` | switch and leave it where it is | | `--keep` | move it, and leave the local copies in place, unread | | `--allow-public` | proceed against a public repository, otherwise refused | | `--replace` | ask for the token again | | `--yes` | skip the confirmations | ```console $ lanes link knowledge use github --repo my-org/my-notes --migrate \ --profile personal --workspace local $ lanes link knowledge use local --migrate --profile personal --workspace local ``` ### `lanes link vault list` Names and descriptions, never values. | | | |---|---| | `--connection <id>` | which vault connection | ```console $ lanes link vault list --profile personal --workspace local ``` ### `lanes link vault get <id>` One item, truncated to a few characters and a length. > **Broken.** `--show` and `--raw` are both refused, so there is no way to reveal a value. The > output tells you to pass `--show`. ```console $ lanes link vault get github_token --profile personal --workspace local pla… (11 chars — --show to reveal) ``` ### `lanes link vault set <id>` Store one item; value on stdin. Not readable over MCP until the endpoint restarts, and it needs a `vault.get.<id>` grant. | | | |---|---| | ~~`--description <text>`~~ | **broken**, refused, though the help text lists it | ```console $ printf %s "$SOME_TOKEN" | lanes link vault set github_token --profile personal --workspace local ``` ### `lanes link vault remove <id>` Delete one item, after asking. The value cannot be recovered. > **Broken in a script.** Asks for `--yes`, and refuses `--yes`. ```console $ lanes link vault remove github_token --profile personal --workspace local ``` ### `lanes link vault key generate` Mint a `LANES_LINK_VAULT_KEY` and print it once. Written nowhere, because a key beside the ciphertext it protects nothing. Needs no profile and no workspace, and refuses both. ```console $ lanes link vault key generate ``` --- ### `lanes link entities find [query]` Look somebody or something up. Every criterion is combined, and a bare `lanes link entities` lists everything. More than one match is a normal answer, and the order is not a ranking. | | | |---|---| | `--type <type>` | only this type: `person`, `company`, whatever you have used | | `--tag <tag>` | only entities carrying this tag | | `--attr <kind>[=<value>]` | has this attribute; repeatable, and `--attr github` means "has one at all" | | `--related <predicate>=<id>` | points at this entity; repeatable | | `--connection <id>` | which entities connection | ```console $ lanes link entities find Jan --profile personal --workspace local $ lanes link entities find --related works_at=acme-bv --profile personal --workspace local ``` ### `lanes link entities get <id>` One entity in full, with its relationships and everything pointing at it. An edge to something not declared yet is shown as such rather than hidden. ```console $ lanes link entities get acme-bv --profile personal --workspace local ``` ### `lanes link entities write <name>` Declare one, or update one that exists. A flag you leave out keeps what is stored, so this is how you correct one field without restating the rest. Notes come from stdin and are optional. | | | |---|---| | `--name <id>` | the id, where the one derived from the name is not what you want | | `--type <type>` | `person`, `company`, `project`, free-form | | `--alias <name>` | another name it is known by; repeatable | | `--attr <kind>=<value>` | an address or handle; repeatable, most-preferred first | | `--related <predicate>=<id>` | an edge from this entity; repeatable, replaces the existing set | | `--tag <tag>` | a label for filtering | ```console $ printf %s "Prefers email over calls." | lanes link entities write "Jan Bakker" \ --type person --alias Jan --attr email=jan@acme.test --related works_at=acme-bv \ --profile personal --workspace local ``` ### `lanes link entities link <from> <predicate>=<to>` Add one edge, leaving everything else alone. It is written only on `<from>`, and the reverse direction is derived, so do not write it the other way as well. The other end need not exist yet, and you are told when it does not. ```console $ lanes link entities link jan-bakker knows=marta-silva --profile personal --workspace local ``` ### `lanes link entities forget <id>` Remove one, after printing it and asking. Edges pointing at it from elsewhere are not rewritten, so it names them first. | | | |---|---| | `--yes` | skip the confirmation | ### `lanes link entities reindex` Rebuild the lookup index from the files. It self-heals on the next write, so this is for when a write is not coming: a bulk edit made in an editor, or a pull from a repository. It says whether it had to do anything, and why. ```console $ lanes link entities reindex --profile personal --workspace local ``` ## Deploying ### `lanes link deploy` Set up what is missing, build the image, roll one revision, print the URL. Names a `--workspace` and sends **every profile that declares it**, because that is the set the endpoint will open. See [`deployment-cloudrun.md`](/docs/link/deployment-cloudrun). | | | |---|---| | `--profile <name>` | only these, repeatable; the first owns the endpoint's token | | `--dry-run` | print every platform command, run none | | `--access iam\|public` | who gets past the platform's own door | | `--iam` | older spelling of `--access iam` | | `--service-account <name>` | the identity the revision runs as | | `--tag <name>` | the image tag; a UTC timestamp by default | | `--yes` | skip the confirmations | | `--non-interactive` | take the stored answers, never prompt | ```console $ lanes link deploy --workspace cloud --dry-run # every profile declaring it $ lanes link deploy --workspace cloud --profile personal --dry-run # only this one ``` A first deploy is the exception: a workspace no profile declares yet has no set to derive, so name the profile it belongs to and `deploy` creates the workspace in it. It refuses two things rather than deciding them. Which profile owns the endpoint's token, when more than one declares the workspace and no previous deploy recorded an answer. One token reaches every profile behind it. And a deploy where two profiles would write the same flat credential reference into the one store the workspace has, which would leave one of them reading the other's account. ### `lanes link sync workspaces` Reconcile the workspace with the copy a deployment reads. Names a `--workspace`; `--profile` narrows it to one. Anything one side is missing is copied to it; anything both sides hold differently stops the run and prints the difference. Credentials, state and the audit log are never copied, only config, skills and provider manifests, which is exactly what a deploy uploads. | | | |---|---| | `--from gs://<bucket>` | where the deployment's copy lives, when nothing here says | | `--discover` | ask the platform to find it, the only route that works from nothing | | `--prefer local\|remote` | which side wins where both disagree | | `--dry-run` | print the differences, write to neither side | ```console $ lanes link sync workspaces --workspace cloud --dry-run $ lanes link sync workspaces --workspace cloud personal ← workspaces.cloud missing locally ← connections.gmail.work missing locally ``` This is how a workspace lost from a profile comes back: the deployment is still running and its bucket still holds the profile as the last deploy left it. Where to look is tried cheapest first: a workspace you still declare, then the `deployments:` index `deploy` writes into `lanes-link.yaml`, then `--from`, then `--discover`. `connect` comes after the first deploy, because a store that does not exist yet is not somewhere to write a credential, and a second deploy is what gets a revision to see those accounts. ### `lanes link secrets list` Credential reference names in this workspace. No command here prints a value. ```console $ lanes link secrets list --profile personal --workspace local ``` ### `lanes link secrets set <ref>` Store one credential value, read from stdin. ```console $ printf %s "ada@example.com:xxxx-xxxx-xxxx-xxxx" \ | lanes link secrets set icloud/ada --profile personal --workspace local ``` ### `lanes link secrets push` Copy credential values between two workspaces' stores. Takes `--profile` only, because `--from` and `--to` name the workspaces, so `--workspace` is refused. | | | |---|---| | `--from <workspace>` | source | | `--to <workspace>` | destination | | `--overwrite` | replace a reference the destination already holds | | `--dry-run` | print what would be copied | ```console $ lanes link secrets push --from local --to cloud --profile personal ``` Copies and never deletes, and skips what the destination has unless `--overwrite`, because the deployed copy may be newer. --- ## Inspection Cheapest first: `check` (static), `doctor` (read-only external), `plan` (what would change), then `start` or `deploy`. ### `lanes link check` Static validation: contract major, no credential values in config, referential integrity, workspaces resolvable. No external call. Takes `--profile` only. ```console $ lanes link check --profile personal ``` ### `lanes link doctor` Credentials resolve, stores reachable, token present, state agrees with config. Also reports capabilities the upstream has grown since you connected. Exits non-zero on a finding, each carrying the command that fixes it. ```console $ lanes link doctor --profile personal --workspace local ``` `--fix` applies the one repair `doctor` can make on its own: a provider this project renamed under you, where the profile still names the old id. That refusal happens at config load, so it takes every other command down with it. `doctor` is the one that still answers, and it moves the connection row, its policy rules and the stored credential together. Everything else `doctor` reports is a decision only you can make, and `--fix` does not touch it. ```console $ lanes link doctor --fix --profile personal --workspace local ``` Without `--fix` it prints what it would move and writes nothing. ### `lanes link plan` What reconcile would change, and nothing else. Reconcile disables undeclared connections, which should never be a surprise. ```console $ lanes link plan --profile personal --workspace local ``` ### `lanes link tools` What the endpoint would hand a client right now, asked over the wire: one `initialize`, one `tools/list`. Not derived from config, which is the point. Reports refused and unreachable as different problems. ```console $ lanes link tools --profile personal --workspace local ``` ### `lanes link audit tail` The log the dispatcher writes, allowed and denied both, arguments redacted per provider. | | | |---|---| | `--limit <n>` | how many entries | | `--denied-only` | the interesting half | | `--format md` | a Markdown table | ```console $ lanes link audit tail --limit 25 --denied-only --profile personal --workspace local ``` ### `lanes link audit verify` Whether anything in the log has been altered or removed. Each record carries the hash of the one before it, so an edit shows as a mismatch, a removal as a sequence gap, and a truncated run as a count that disagrees with its marker. Exits non-zero on a break. | | | |---|---| | ~~`--limit <n>`~~ ~~`--format <f>`~~ | **broken**, accepted and ignored; it walks every chain regardless | ```console $ lanes link audit verify --profile personal --workspace local ``` ### `lanes link config show` The resolved config as JSON. Takes `--profile` only. ```console $ lanes link config show --profile personal ``` ### `lanes link version` Which release this is. Same as `lanes --version`. Needs no profile and no workspace. ```console $ lanes link version ``` ### `lanes link update` Install the newer release. There is no build step, so an update replaces the installed package directory. Bun only; from a checkout it refuses and says `git pull` is the update there. Needs no profile and no workspace. | | | |---|---| | `--check` | report only, exiting non-zero when a newer release exists | ```console $ lanes link update --check ``` --- ## Attachments ### `lanes link attach <file>` Stage a file and print the handle a send can name it by. A `path` attachment names the filesystem the *endpoint* sees, which stops meaning anything once the endpoint is not where the file is; a handle does not care. | | | |---|---| | `--connection <provider>.<account>` | which connection the attachment belongs to | ```console $ lanes link attach ./report.pdf --connection gmail.main --profile personal --workspace local ``` --- ## Aliases ### `lanes link skill` The older spelling of `mcp skill`, from before `lanes link skills` existed. Kept working, unadvertised. | | | |---|---| | `--print` | write the document to stdout | | ~~`--force`~~ | **broken**, accepted and ignored | ```console $ lanes link skill --print ``` ### `lanes link help` The command list at a glance. Also `lanes link --help`, and `--help` on any command. ```console $ lanes link help ``` --- ## Selection **Workspace root:** `LANES_LINK_HOME`, then the nearest ancestor holding `lanes-link.yaml`, then `~/.lanes-link`. **Profile and workspace:** `--profile` and `--workspace`, and nothing else. No variable, no config key, no default ([ADR-037](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/037-a-command-names-what-it-acts-on.md)). A command naming neither refuses and lists what there is to choose from. `instance.default_target` and `default_profile` are still parsed and no longer read, so `check` and `doctor` can tell you the line is inert. **Refuse `--workspace`:** `check`, `config show`, `policy list`, `identity list`, `secrets push`. **Require no workspace:** `workspace list`. **Name neither:** `profile add`, `profile list`, `profile default`, `workspace use`, `mcp list`, `mcp skill`, `skill`, `vault key generate`, `version`, `update`, `help`. Source: https://lanes.sh/docs/link/commands --- # Configuration > The connection list, the profile file, grant grammar, workspaces, environment variables A **connection** is one authorised account, and it belongs to the workspace. A **profile** is a selection: which connections it includes, what it allows on each, and who may consume it. One endpoint serves every profile in the workspace. ``` lanes-link.yaml the workspace registry, and the default workspace connections.yaml every authorised account in this workspace profiles/ personal.yaml assistant.yaml data/ everything the workspace owns, gitignored state.kv/ connections, provider state, cursors audit.log/ one object per event credentials.enc every connection's credentials, and its .key vault.d/<id>.enc one file per vault connection skills.d/<id>/ one directory per skills connection providers.d/ your own provider manifests <provider>/<connection>/... ``` A real config is gitignored; only `*.example.yaml` is committed. **This changed in 0.8.0, and it is the change everything else follows from.** A connection used to live inside one profile, invisible to its siblings, so reaching the same mailbox from two profiles meant authorising it twice. Now you authorise once and each profile decides what it may do with it. `data/` moved up with them, since a credential store per profile made no sense once the credentials were the workspace's. Upgrading is automatic. `lanes link start` and `lanes link update` migrate a contract 2 workspace in one pass: connections are hoisted and merged, credentials rewritten, blobs moved, and each profile rewritten with the grants its old flat policy implied. Two profiles that each held `gmail.main` for *different* accounts keep both, one renamed, and the rename is reported. `lanes link migrate --dry-run` prints the whole plan first. Nothing under `data/` is shared between workspaces. Skills and provider manifests are per connection, which is what makes `skills.main` and `skills.acme` separable and lets a profile pick one ([ADR-059](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/059-the-owner-layer-is-instances.md)). `data/` is gitignored, so skills are not committable where they sit. If you keep procedures in version control, keep them in their own repository and copy them in, or point the workspace at one with `lanes link knowledge use github`. Un-ignoring a path inside `data/` is not worth it: the directory beside them holds an encrypted credential store and the key that opens it. ## Every connection in the workspace ```yaml # ~/.lanes-link/connections.yaml contract: 3 # App registrations, shared by every connection of that vendor. # # Also the switch. A provider whose manifest names a broker, which every Google # REST provider does, authorises against the client that broker operates when # there is no entry here, and against yours when there is. Written for you by # "lanes link connect <provider> --own-client". Deleting it is not enough to go # back: the client is also looked for in the credential store, so a workspace # whose config lost this block is not silently moved onto a different client and # left holding refresh tokens the new one refuses. Removing both is what # switches. oauth_apps: google: client_id_ref: google/client_id client_secret_ref: google/client_secret # One entry per authorised account. "account" is the identity the provider # reports, resolved at connect time, so this list answers "whose mailbox is # this" without a lookup. The id derives from it and is not usually written. # # The owner layer arrives here as named instances. They reach your own material, # so there was never anything for a connect step to authorise (ADR-050), and # they are separable: memory.main and memory.acme are two stores. connections: - { id: main, provider: memory, account: Memory } - { id: main, provider: tasks, account: Tasks } - { id: main, provider: assets, account: Assets } - { id: main, provider: skills, account: Skills } - { id: main, provider: vault, account: Vault } - { id: main, provider: setup, account: Setup } - { id: main, provider: entities, account: Entities } - id: ada_lovelace provider: gmail account: ada.lovelace@example.com - id: rin_shaw provider: gmail account: rin.shaw@example.com ``` Two of the owner layer may only be granted once per profile. `skills` and `vault` each back one directory and one sealed file per profile, so a second instance in the same profile would be two names for one store. ## A complete profile ```yaml contract: 3 instance: profile: assistant port: 7337 host: 127.0.0.1 # What this profile is for. Shown on the dashboard and in setup_overview, and # the thing that makes a selection legible to somebody who did not make it. description: Personal assistant for Semin # It says nothing about where it runs, and that is the point. A profile lives in # exactly one workspace, and the workspace is the one holding this file, which # declares its adapters once, in lanes-link.yaml beside profiles/ (ADR-052). # The bearer token for the endpoint this profile serves. CI only from 0.8.0: a # human caller signs in with Lanes and the client authorises itself, so this is # for a headless runner with no browser to complete a flow in. auth: mode: bearer token_ref: profile/token # Browser origins allowed to call /mcp. Absent means "*", so this is only # worth setting to narrow it. Deployment only, because a loopback endpoint # refuses every cross-origin request, and this cannot widen that. ADR-039. allowed_origins: ['*'] limits: requests_per_minute: 120 # per profile upstream_calls_per_minute: 60 # per connection, protects vendor quota # The selection, and the whole of what this profile may reach. # # One row per connection, naming it by "<provider>.<id>" from connections.yaml. # A connection with no row here is not merely denied: it is absent, and an agent # asking this profile never learns it exists. # # Rules are per row, which is the point of the shape: read one mailbox, write # the other, edit the calendar. Under contract 2 a single flat policy governed # every account of a provider identically, and that was the limitation this # replaces (ADR-058). grants: - connection: gmail.ada_lovelace allow: [gmail.users.messages.list, gmail.users.messages.get, gmail.users.threads.*] - connection: gmail.rin_shaw allow: ['gmail.*'] deny: [gmail.users.drafts.send] - connection: memory.main allow: ['memory.*'] - connection: tasks.main allow: ['tasks.*'] # Who may consume this profile. Empty is nobody, not everybody. # # A subject is a Lanes identity, written by "lanes auth login". A remote # workspace bound to a Lanes workspace may name anyone that workspace holds; a # local one may name only you (ADR-060). members: - { subject: lanes:3QBmAxJLLrYSMTVUIeCN1SKFbdD3, role: owner } # Optional. Who the owner is, for anything written as them: a name to sign # with, an address to send from, a handle to attribute to. Order is the # ranking: the first of a kind is the default, and the note says when to # prefer another. identity: - { kind: name, value: Ada, note: use for open-source work } - { kind: name, value: A. Lovelace, note: use on anything published } - { kind: email, value: ada.lovelace@example.com } - { kind: github, value: octocat } ``` There is no `providers` block: declaring a connection is what enables a provider, and a second place to say so could only ever disagree with the first. There is no `connections:` block either, and no `policy:` one; both are what `grants:` replaced. ## Config never contains a credential Only `_ref` pointers into the credential store. This is enforced, not merely intended: the loader rejects private key blocks, known vendor prefixes (`sk-`, `xoxb-`, `ya29.`, `ghp_`, …), high-entropy blobs, and any key naming a credential that holds a literal instead of a `_ref`. Naming the exact path: ``` personal.yaml: Configuration must not contain credential values, only "_ref" pointers. This value looked like a credential: oauth_apps.google.client_secret starts with "ya29.", which identifies a Google OAuth access token ``` There is deliberately no suppression flag. ## Identity `kind` is any lowercase identifier, so `name`, `email` and `github` are conventions rather than a list this project ships: `linkedin`, `phone`, `pronouns` and `signature` need no code change. `value` is the name or address. `note` is prose, read by whatever is writing as you. Declare one through the CLI rather than by hand, because the block on its own is inert: ```console $ lanes link identity add name "A. Lovelace" --note "use on anything published" --profile personal --workspace local $ lanes link identity add email ada.lovelace@example.com --profile personal --workspace local $ lanes link identity list --profile personal ``` The first of those writes three things: the entry, an `identity` row in `connections.yaml`, and a `grants:` row allowing `identity.*` in this profile. All three are needed before anything can read it, and a connection with no grant row is filtered out before policy is consulted, so an `identity` block by itself is a file that says exactly what you meant and an agent that cannot see a word of it. `identity list` says `declared, but no agent can read it` when that is the state. What reads it is one read-only tool, `identity_list`. Nothing on the MCP surface can write here: an agent able to edit this could edit the one fact that stops it signing as the wrong person, so editing is CLI-only under ADR-007. The endpoint's own instructions carry a pointer to the tool and none of the values. See [ADR-042](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/042-a-profile-declares-who-its-owner-is.md). Removing the last entry leaves the row and the rule in place, and the tool then reports that nothing is declared. ## A profile that uses the hosted OAuth client The default, and the shorter file: there is no `oauth_apps` block at all, because there is no client to point at. The Google connections below authorise against the client Lanes operates, and its secret is never on this machine. ```yaml # connections.yaml contract: 3 connections: - id: ada_lovelace provider: gmail account: ada.lovelace@example.com ``` ```yaml # profiles/personal.yaml contract: 3 instance: profile: personal grants: - connection: gmail.ada_lovelace allow: ['gmail.*'] members: - { subject: lanes:3QBmAxJLLrYSMTVUIeCN1SKFbdD3, role: owner } ``` Adding an `oauth_apps` entry later does not move an existing connection onto your client: which client minted a refresh token is recorded with the token, because one client's refresh token is refused by another. Run `connect` again for any connection you want moved. See [ADR-028](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/028-a-hosted-oauth-client-is-the-default.md). ## Removing a profile ```console $ lanes link profile remove work --profile personal ``` It prints what it would delete, then asks you to type the profile name. `--dry-run` stops after the preview, `--yes` skips the prompt, and `--workspace <name>` decommissions one workspace while leaving the profile itself in place. What goes: the profile's config, and, in **every workspace it declares**, its credentials, state, audit log, provider blobs, vault, skills, and provider manifests. For a workspace whose home is a bucket, the copy of the config a deployed revision reads goes too. Skills and manifests go because they are inside the profile's own directory ([ADR-030](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/030-a-profile-owns-its-skills-and-manifests.md)); before that they were shared and this command left them alone. Nothing another profile can see is deleted. What stays, and each for a reason: - **`lanes-link.yaml`.** If it still names this profile as the inert `default_profile`, that key is cleared rather than repointed at whatever remains, because choosing a new default would silently change what every other command in the workspace acts on. - **Infrastructure.** No Cloud Run service, bucket, or service account is touched. `deploy` created those and can recreate them; removing them needs permissions this command should not hold. - **Credentials this profile does not declare.** In Secret Manager, references are flat names in one project, so two profiles deployed to the same project share a namespace. Only what this profile declares is deleted; anything else is listed in the preview and left alone. Removal is best effort. If a store cannot be reached, whether a project deleted or an expired login, the rest still goes, every survivor is named with the command that finishes it, and the exit code is non-zero. The profile's config is **kept** in that case, so nothing is stranded and the retry is the same command again. A deployed workspace is called out in the preview: the service keeps answering, and every call fails, because what it served is gone. ### Going back to the hosted OAuth client This is the blunt instrument for it. The precise one is to remove the `oauth_apps` entry *and* the stored `google/client_id` and `google/client_secret`, both of them, because the client is looked for in the credential store as well as in config. Then run `connect` again for each account: a refresh token is only accepted by the client that minted it. ## Validation rules 1. **An unknown contract major fails closed.** Never a best-effort load, because this file governs authorization, and guessing at a schema we do not implement risks reading it as more permissive than it was written. 2. **Credential-shaped values are rejected**, with the offending path named. 3. **`_ref` values must be well-formed.** Existence is checked by `lanes link doctor`, not the loader, so an unconnected account does not block startup. 4. **Ids are unique per provider.** `gmail.main` and `icloud_mail.main` coexist. 5. **A grant naming a connection that does not exist fails**, and it names the connection and lists what the workspace does hold. A grant is a reference now rather than a description, so a typo in one is a profile that quietly reaches nothing. 6. **An `allow` rule naming a provider with no connection fails**, because a rule that silently grants nothing looks identical to a working one. A `deny` may name one: withholding something ahead of connecting it is reasonable, and refusing that would punish the cautious ordering. 7. **`--workspace` must name a declared workspace.** `default_workspace` in `lanes-link.yaml` supplies it when the flag is absent, and the resolved name is echoed on every command that used it (ADR-061). 8. **A CLI write validates before writing**, and never leaves the file invalid on failure. ## Grant grammar ```yaml grants: - connection: gmail.ada_lovelace allow: - '*' # everything, which is what connect writes - gmail.* # one provider - gmail.users.labels.list # one capability deny: - gmail.users.drafts.send ``` Three forms and no more. `gmail.*` matches `gmail.search` but not `gmailx.search`, because the dot is part of the prefix. `gmail.*.read` and `gm*` are rejected. There is no policy expression language, deliberately: every additional operator is another way to believe you wrote something narrower than you did. **Rules name capabilities, and the row names the connection.** That is the 0.8.0 change. Two mailboxes in one profile are two rows and may be governed differently, which is what makes "read this one, write that one" expressible at all. Under contract 2 every account of a provider within a profile was governed identically, and the only way to separate them was a second profile ([ADR-058](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/058-a-grant-names-a-connection.md)). **A connection with no row is absent, not denied.** It is filtered out before policy is consulted, so it does not appear in the `connection` enum of any tool and an agent never learns it exists. A **deny beats an allow regardless of order in the row**, including the catch-all `'*'` that `connect` writes. For an expiry, a rule may take its object form instead: ```yaml - { capability: gmail.*, expires_at: "2027-01-01T00:00:00Z" } ``` Editing a row from the CLI names the connection, because a rule now has to land somewhere: ```console $ lanes link policy allow gmail.users.messages.list --connection gmail.ada_lovelace --profile assistant $ lanes link policy deny gmail.users.drafts.send --connection gmail.rin_shaw --profile assistant $ lanes link grant add gmail.ada_lovelace --profile assistant $ lanes link grant remove gmail.rin_shaw --profile assistant ``` ## Members A profile declares who may consume it, and nothing else may. ```yaml members: - { subject: lanes:3QBmAxJLLrYSMTVUIeCN1SKFbdD3, role: owner } ``` **Empty is nobody.** A blank list naturally reads as "no restriction", and it means the opposite: no caller reaches this profile at all. `lanes link profile members list` says so out loud rather than printing nothing. A subject is a Lanes identity, and it is written for you: ```console $ lanes auth login $ lanes link profile members add --me --profile assistant $ lanes link profile members add lanes:<subject> --profile assistant $ lanes link profile members list --profile assistant ``` **This is a selection from the Lanes workspace, not a second list beside it.** A workspace bound with `lanes_workspace:` is asked who it holds, so somebody added on the dashboard appears here the moment they accept, and `members list` shows both halves: who may consume this profile, and who could be given it. A pending invitation has no subject yet, so it is listed, marked, and refused. An unbound workspace has no list to ask, so the only subject it accepts is the one signed in at the keyboard. A local workspace delegating to a stranger is a typo rather than a use case. Removing somebody does not end a session they already have. Membership is read when a token is minted, not on every call, so a token they hold keeps working until it expires. `lanes link token rotate --workspace <name>` closes that window now, and the removal says so. Editing `members:` is CLI-only, for the sharpest version of the ADR-007 argument: an agent able to edit this could add itself. ## Workspaces A workspace holds connections and profiles, and says once, in its own `lanes-link.yaml`, where their bytes go. **"Target" is gone.** It named the same thing a workspace already was, and one concept with two words is one word too many ([ADR-061](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/061-a-workspace-is-the-only-word.md)). `--workspace` is accepted for one minor as a deprecated alias that warns, and passing both spellings with different values is refused rather than resolved. ```yaml # ~/.lanes-link/lanes-link.yaml contract: 3 default_workspace: local workspaces: local: # this workspace is the "local" one credentials: { adapter: file } storage: { adapter: filesystem } cloud: at: gs://your-bucket # a pointer; that workspace declares it lanes_workspace: 6f0c... # whose members this may delegate to ``` An entry is either a **declaration**, meaning `credentials` and `storage` plus whatever else the adapter set needs, or a **pointer**, carrying `at:` and nothing else. Both is refused: two answers to "where do this workspace's bytes go" is the state that let a rewritten profile report seven connections for a bucket holding fifteen. (`workspace:` was the old spelling of the pointer; `workspaces.acme.workspace` read badly enough to be worth changing with the rest.) Following a pointer is a read of that workspace's own file, so `--workspace cloud` needs the bucket reachable. Offline it says so, rather than answering from a local copy that may be hours stale. ### The default, and the commands that refuse it `default_workspace` is a key that is actually read, which is new. ADR-037 removed a sticky selection because a command acting on the wrong thing silently is worse than typing a flag, and that argument still holds for anything that publishes or destroys. So the default is **echoed on every command that uses it**, and **refused by the commands where being wrong is expensive**: ```console $ lanes set-workspace local $ lanes link status # prints: workspace local (default) $ lanes link deploy # refused: name --workspace explicitly ``` The set that refuses it: `deploy`, `sync`, `sync workspaces`, `secrets push`, `profile remove`, `disconnect`, and `token rotate`. `connect` is deliberately not among them; it creates rather than destroys, and putting ceremony on the onboarding command is what ADR-043 warns against. `lanes link workspace list` prints the registry without following any pointer, so it is instant and works offline; `lanes link workspace show <name>` follows one and reports what is really there. ### What a deploy writes back `lanes link deploy` stamps three fields onto the entry, on **both** ends: the workspace's own workspace file and the pointer here: ```yaml contract: 3 workspaces: cloud: at: gs://your-bucket primary: personal # whose token opens the endpoint (ADR-009) last_deploy: "2026-08-28T09:00:00.000Z" last_deploy_version: "0.6.6" # the release that rolled the revision ``` `last_deploy_version` is the CLI release that ran the deploy, which is the code the endpoint is running: the image is built from the installed package, so the two cannot differ. It is written *after* the rollout, so a build that failed leaves the previous version in place rather than claiming one that never served a request. Keeping it on the pointer as well as in the bucket is what makes it readable offline, `lanes link workspace list` deliberately follows no pointer, and `lanes link workspace show <name>` prints it beside `last_deploy`. Nothing reads these three; they are a record, and every command works without them. | Interface | `local` | `cloud` | |---|---|---| | SecretStore | encrypted file | Google Secret Manager | | BlobStore | filesystem | `gcs`, or any S3-compatible bucket | | VaultStore | encrypted file | Google Secret Manager, one sealed entry | **Two adapters, and that is the whole of the difference.** There is no `database:` block: runtime state is one object per key in the same `BlobStore`, and the audit log is one object per event beside it (ADR-020). A profile written before that keeps its `database:` key and it is ignored. Credentials follow the workspace, because each workspace has its own credential store. ### More than one deployment `local` and `cloud` are conventions, not keywords. Nothing reserves either name, and a workspace may know as many workspaces as it has places to reach. A second deployment is named on the deploy that creates it (`lanes link deploy --workspace staging`), which surveys for what it does not know, writes the declaration into the workspace it creates, and leaves a pointer here. `lanes link workspace list` prints the registry without following any pointer, so it is instant and works offline; `lanes link workspace show <name>` follows one and reports what is really there. `lanes link target use` has been removed (ADR-037), and `lanes set-workspace` is not it returning: the sticky value it writes is echoed on every command that reads it and refused by every command that publishes or destroys (ADR-061). A profile lives in exactly one workspace. `personal` on `local` and `personal` on `cloud` are two files, in two workspaces, that happen to share a name, which is why every command names both. Each workspace's credential store is its own, so a connection authorised against `cloud` is absent from `staging`. `lanes link secrets push --from cloud --to staging` copies them across instead of re-running every consent. Two cloud blob adapters, and the difference is setup rather than capability. `gcs` authenticates as the identity already present, whether the service account `lanes link deploy` grants `objectAdmin` or your own gcloud credentials locally, so the bucket needs **no credential of its own**. `s3` needs an endpoint and an HMAC key pair, which on GCS means a console visit to mint one; it is the answer for R2, MinIO, Supabase Storage, and AWS. **`BlobStore` is not optional in the cloud.** State, the log, memory, tasks, assets, and skills all live in it, and a container filesystem loses every one of them on an instance recycle without reporting anything. ### The vault block Optional, and defaulting to `file`, so a profile that predates it keeps working and a local run needs no vault configuration at all: ```yaml # in that workspace's lanes-link.yaml workspaces: cloud: vault: { adapter: blob } # the workspace's own storage ``` The `blob` adapter **requires `LANES_LINK_VAULT_KEY`** and will not mint a key. The file adapter may, because it writes one to a sibling `<path>.key` at mode 0600 that outlives the process; a deployment has no equivalent, and a key generated per revision would make every stored item permanently unreadable while appearing to work. Mint one with `lanes link vault key generate`. ### The knowledge block Optional, and absent by default. It moves **memory entries and skills** into a GitHub repository, reached over the API, and it can move nothing else: runtime state, the audit log, tasks, assets, the credential store and the vault stay wherever `storage:` and `credentials:` put them. Tasks could reasonably follow later; assets raises a different question, since binaries in a git repository is not the trade Markdown is ([ADR-041](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/041-memory-and-skills-in-a-repository.md)). ```yaml workspaces: local: knowledge: adapter: github repo: my-org/my-notes # owner/name, not a URL branch: main # optional; the repository's default branch otherwise path: context # optional prefix, for a repository holding other things token_ref: knowledge/token # a reference, never the token ``` The repository then holds two directories, `memory/<connection>/<id>.md` and `skills/<name>/SKILL.md`, under `path` if one is given. You do not write this by hand: ```console $ lanes link knowledge use github --repo my-org/my-notes --migrate $ lanes link knowledge show $ lanes link knowledge use local --migrate # the same thing backwards ``` That command asks for the token, refuses a repository the token cannot write, **refuses a public one** unless `--allow-public` says otherwise, moves what is already stored in a single commit, reads it back before deleting anything, and writes the block into every workspace the profile declares. Each workspace reads the token from its own credential store, so a second one needs `lanes link secrets push --from local --to cloud`. The token is its own credential and deliberately not the one `lanes link connect github` holds: that one needs Contents **read**, this one needs Contents **write**, and revoking either should not affect the other. **What it costs**, in one place, because none of it is a fault: | | | |---|---| | Offline | Nothing works. There is no local cache, because a second copy can disagree with the repository. | | `memory.search` | Reads every entry by design. The first search after a change fetches what changed; after that they come from a cache keyed by content sha. | | Rate limit | GitHub's 5,000/hour becomes one of this endpoint's own failure modes. The branch is polled conditionally and a `304` costs no quota, so an idle endpoint costs nothing. | | History | Every write is a commit. That is the feature, and it means deleting an entry does not remove it from the history. | | `profile remove` | Does not touch the repository. It plans against the workspace's declared storage, so memory and skills survive removing the profile, and the plan says so before you confirm. | [ADR-041](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/041-memory-and-skills-in-a-repository.md) has the reasoning, including why this is the API rather than a clone. ## Environment variables | | | |---|---| | `LANES_LINK_HOME` | Workspace root. Otherwise the nearest ancestor holding `lanes-link.yaml`, else `~/.lanes-link`. | | `LANES_LINK_PROFILE` | **No longer read** (ADR-037). Pass `--profile`. Named in the refusal when it is set, so a shell configured for the old behaviour says so. | | `LANES_LINK_TARGET` | **No longer read** by the CLI (ADR-037). Pass `--workspace`. Still how the container entrypoint selects its adapter set, because a deployed revision has no argv. | | `LANES_LINK_HOST` / `PORT` | Bind address and port in a container. | | `LANES_LINK_CREDENTIAL_KEY` | base64 32-byte key for the encrypted credential store. | | `LANES_LINK_VAULT_KEY` | base64 32-byte key for the vault. **A different key, deliberately**: one master secret reused across purposes turns any single compromise into a total one. | | `LANES_LINK_TOKEN` | Convention only: where `lanes link mcp add codex` tells the harness to read the bearer token from. | | `LANES_LINK_APP_SCHEME` | Which Lanes build `lanes link desktop` opens. `lanes` by default; `lanes-dev` and `lanes-stage` reach a local debug or Stage build, which register their own URL schemes. | The two encryption keys are never interchangeable and never shared. `/docs/link/security` explains why credentials and vault items are different kinds of secret. ## Reconcile On boot: upsert declared entities; mark undeclared connections **disabled rather than deleted**, preserving audit history; mark a connection whose credential is missing `unauthorized` **without blocking startup**. One half-configured account must not stop the profile from serving; report drift in both directions. `lanes link plan` prints exactly what reconcile would change, without mutating anything. It exists because reconcile disables undeclared connections, and that should never be a surprise. Source: https://lanes.sh/docs/link/configuration --- # Attachments > Sending a file through an agent, without the bytes ever passing through the model `gmail.send_message` and `icloud_mail.send_message` both take an `attachments` list. **The bytes never pass through the model.** A file is *named*, and the endpoint reads it. That is the whole design. What follows is how to use it. ## Naming a file Each entry carries **exactly one** source. Naming two is an error rather than a precedence rule, because silently preferring one would make the other look like it worked. ```jsonc { "path": "/Users/you/Downloads/invoice.pdf" } // read from this machine { "url": "https://example.com/invoice.pdf" } // fetched here, over HTTPS only { "handle": "att_01j7k…" } // staged earlier, see below { "message_id": "18f…", "attachment_id": "quote.pdf" } // already in this mailbox { "data": "JVBERi0…", "filename": "invoice.pdf" } // inline base64, last resort ``` `filename` and `content_type` are optional overrides on any of them. `attachment_id` may be a filename, a 1-based position, or, on Gmail, the vendor's own id. Omit it when the message has one attachment. ## Forwarding costs nothing `message_id` resolves inside the endpoint, so re-sending a PDF that arrived by mail never materialises it anywhere a context window can see. Prefer it over reading an attachment and passing the bytes back. The read side returns metadata only, so that route does not exist anyway. ## Sending from a deployed endpoint `path` means the filesystem the *server* can see, which on Cloud Run is a container. Stage the bytes instead: ```console $ lanes link attach ~/Downloads/invoice.pdf --connection gmail.you att_75f5be471a18b0f7… invoice.pdf 239104 bytes ``` Or `POST /attachments?connection=gmail.you` with the file as the body, an `X-Filename` header, and the usual bearer token. Either way you get a handle to pass as `{ "handle": "…" }`. Handles belong to the connection they were staged for, expire after a day, and are swept on the next upload. A handle staged for `assets/main` is deliberately unresolvable from `gmail/main`, so use `lanes link attach --connection gmail.you` to mail a file you keep in assets. ## Size Gmail accepts 35 MiB and iCloud 20 MB, both counted *encoded*. Attachments travel as base64, so usable file weight is about three quarters of that. An oversized send is refused before anything is submitted, because a message rejected part-way through `DATA` reads like a dropped connection. ## What the log keeps Recipients and bodies are withheld, as always. Per attachment it records the filename, byte length, content type, SHA-256, and where the bytes came from, including the resolved absolute path. `path` is deliberately unrestricted, so that record is the only trace of which file left the machine. It is what makes "was this ever mailed out" an answerable question. The tool result is the same receipt, never the content. ## Give a connection a display name Only the SMTP path needs this. Gmail is asked for no `From` at all, so it fills the header from the credential, display name included. SMTP submits exactly what is composed, so without a name the header is a bare address: ```yaml connections: - id: rin_shaw provider: icloud_mail account: rin.shaw@example.com config: from_name: Ada Lovelace ``` `from_name` on the call overrides it. This is not a spam control. What decides placement is alignment, and that is already healthy: a message sent this way arrives `dkim=pass header.i=@icloud.com` and `spf=pass`, signed and sent by iCloud's own infrastructure, because the endpoint submits through the account rather than spoofing it. A missing display name is a trust problem rather than a deliverability one, and for client-facing mail that is reason enough. ## An older iCloud connection needs re-connecting once IMAP capabilities are discovered and cached in the profile database, so a connection made before attachments existed keeps the old schema and never shows `attachments`, however new the code is: ```console $ lanes link connect icloud_mail --profile personal --workspace local ``` The stored app password is reused, so it is non-interactive. Gmail needs nothing, because its send is authored rather than discovered. Watch the failure mode in between: an endpoint still running the *old* code advertises `attachments` and silently ignores it, which is a mail that says it has an attachment and does not. `lanes link plan` does not warn about it, because it compares capability names and this changed a schema. --- **Next:** [Google](/docs/link/google) and [iCloud](/docs/link/icloud) are the two accounts that send mail. Source: https://lanes.sh/docs/link/attachments --- # Workflow > The lifecycle end to end, in order The normative CLI contract, in the order you meet it. Implement against this; keep it updated when a command changes. To look one command up rather than follow the lifecycle, see [`commands.md`](/docs/link/commands). **Every command prints the resolved profile and workspace before acting**, read-only commands included. It is the primary guard against operating on the wrong instance, and it costs one line. ``` profile personal workspace local /Users/you/.lanes-link ``` The parenthesised source matters: `profile: work` is much less useful than knowing it came from an environment variable you forgot you exported. ## From nothing to a working endpoint No external service and no credentials of any kind: ```console $ lanes link profile add personal --workspace local ok created profile personal config ~/.lanes-link/profiles/personal.yaml port 7337 workspaces local Next: lanes link connect example --profile personal --workspace local $ lanes link connect example --profile personal --workspace local ok connected example.main providers.example.enabled = true connections += example.main granted read bundle: + example.echo + example.get_note + example.list_notes Next: lanes link start $ lanes link start --profile personal --workspace local profile personal workspace local ~/.lanes-link + example.main create (active) ok reconciled warn minted a profile token — run: lanes link outputs --show ok serving http://127.0.0.1:7337/mcp Ctrl-C to stop. ``` In another shell: ```console $ lanes link outputs --profile personal --workspace local --show Endpoint http://127.0.0.1:7337/mcp running Profiles reachable through it (1) personal (endpoint owner) Each call names one, in its `profile` argument. Token llk_… Register with your agent Not run for you: which config file an agent reads is its business, not ours. claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \ --header "Authorization: Bearer $(lanes link token show --raw)" ``` **The registration format is your agent's business, not ours.** Nothing in `lanes link` writes an agent's config file. `lanes link mcp add` runs each harness's own `mcp add`, and the command above is what it runs. Note the `$(…)`: the token is substituted by your shell, so it never passes through an agent's context. It is resolved once and stored as a literal, so a `token rotate` means registering again. What `mcp add` *does* write is the agent skill, into the directory that harness reads it from: there is no `claude skill add` to delegate to ([ADR-016](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/016-what-the-endpoint-says-about-itself.md)). ## Adding accounts One command, run once per account. The second run skips whatever the first established. ```console $ lanes link connect example --profile personal --workspace local # → example.main $ lanes link connect example --profile personal --workspace local # → example.main2 $ lanes link connect gmail --profile personal --workspace local # → straight to the browser, nothing to register $ lanes link connect gmail --profile personal --workspace local # → again, another account ``` `lanes link connect gmail.main` re-authorises one existing account. `--id` overrides the derived connection id; `--display-name` sets the label. To see what one takes before starting, meaning the console work, the values it will ask for, and whether a browser is involved: ```console $ lanes link setup plan --profile personal --workspace local # every provider, connected or not $ lanes link setup plan icloud_mail --profile personal --workspace local # the steps, the values, the command ``` ### Without a terminal to answer `--non-interactive` never prompts. It resolves every value the manifest declares from the credential store before writing anything, and refuses with what is missing and the command that stores it: ```console $ lanes link connect icloud_mail --profile personal --workspace local --id ada --non-interactive --json { "ok": false, "reason": "missing_credentials", "needs": [{ "ref": "icloud/ada", "command": "printf %s \"<username>:<password>\" | lanes link secrets set icloud/ada --profile personal" }], "then": "lanes link connect icloud_mail --profile personal --id ada --non-interactive" } $ printf %s "ada@example.com:xxxx-xxxx-xxxx-xxxx" | lanes link secrets set icloud/ada $ lanes link connect icloud_mail --profile personal --workspace local --id ada --non-interactive --json ``` Credentials go in through `secrets set` on stdin, never as a flag. An argument is in the shell history, in `ps` output while the process runs, and in any transcript. A provider that authorises in a browser is refused rather than attempted: the consent belongs to whoever owns the account, and a listener nobody is watching times out after five minutes having achieved nothing. Where scopes are broader than a provider needs, the run stops and prints `--accept-broad-scopes` for a person to add deliberately. ## Two profiles side by side One endpoint serves every profile in the workspace, and each call names the profile it means: ```console $ lanes link profile add work ok created profile work $ lanes-link --profile work connect notion $ lanes-link --profile work policy deny notion.create-pages $ lanes link start --profile personal --workspace local ok serving http://127.0.0.1:7337/mcp profiles: personal, work ``` Register it **once**: one URL, one token, both profiles ([ADR-009](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/009-one-endpoint-per-workspace.md)): ```console $ lanes link mcp add --profile personal --workspace local # every harness installed; or: lanes link mcp add codex ok registered lanes-link with Claude Code (user scope) installed skill at ~/.claude/skills/lanes-link/SKILL.md installed scout agent at ~/.claude/agents/lanes-link-scout.md ok registered lanes-link with Codex installed skill at ~/.codex/skills/lanes-link/SKILL.md $ lanes link mcp list Registered as lanes-link Claude Code registered skill: up to date scout agent: up to date Codex registered skill: up to date ``` Two halves, with different rules. The registration shells out to each harness's own `mcp add`, so nothing here writes an agent's config file. The documents are written directly, because no harness has a command for installing one. Run `mcp add` again after an upgrade and it refreshes them, reporting `unchanged` when there was nothing to do. `--no-skill` skips that half. For anything else, `lanes link outputs` prints the URL and a command to adapt, and the endpoint tells it the short version of the same thing over MCP's `instructions` when it connects. Every tool then takes a `profile` argument beside `connection`, and policy is evaluated against the one named. `--only` serves the resolved profile alone. Be clear-eyed about what this does and does not separate. Profiles share no database and no credential store, so what one holds is invisible to another. They do share an endpoint and its token, so an agent holding that token can reach either by asking. **If you need a boundary that holds against the agent itself, use a second workspace**, which shares nothing at all. ## Where a profile runs A **workspace** names an adapter set: a credential store and a blob store, and optionally a deployment. Connections, providers, policy and limits are declared once and apply to every workspace, so moving between them changes where the bytes go and nothing above them. ```console $ lanes link workspace list --profile personal profile personal workspace local ~/.lanes-link cloud gcp-secret-manager gcs cloudrun my-service europe-west1 staging gcp-secret-manager gcs — * → local file filesystem — * instance.default_target — what commands run against → what this shell resolves to right now ``` Two markers, because two things choose and they can disagree. `*` is the profile's `instance.default_target`; `→` is what *this shell* resolves to, which `LANES_LINK_TARGET` or `--workspace` may have moved. When they differ, the listing says which variable did it. ```console $ lanes link target use cloud # rewrite instance.default_target, for good $ export LANES_LINK_TARGET=cloud # or just for this shell $ lanes link workspace show cloud --profile personal # adapters, deployment, and the address it answers on ``` `workspace list` reads the file and asks nobody; `--urls` adds one platform lookup per deployable workspace. `workspace show` always asks, because it is one workspace and you named it. Note what `--workspace` does *not* do: it never points the CLI at a running endpoint. Every command opens that workspace's stores directly, so `lanes link connect gmail --workspace cloud` runs the browser consent on your machine and writes the refresh token into the deployment's credential store. The deployed revision picks it up when it next boots, which is what the second `deploy` below is for. ## Selection **Workspace root:** `LANES_LINK_HOME` → nearest ancestor containing `lanes-link.yaml` → `~/.lanes-link`. This one still resolves, deliberately: getting it wrong yields "no profiles here" rather than an action against the wrong account, and it is the only channel a container has for its bucket. **Profile and workspace:** `--profile` and `--workspace`, and nothing else (ADR-037). No environment variable, no key in a file, no default. A command that names neither refuses and lists what there is to choose from. ```console $ lanes link status error --profile is required. Every command names the profile it acts on, and nothing else selects one. Profiles in ~/.lanes-link personal work ``` There used to be a chain (flag, then variable, then config key) and the argument for it was that each step was *visible*: `env` shows a variable, `check` validates a key, and every command printed which of the four it had landed on. What that missed is that a fallback makes an ignored flag survivable. `lanes link profile add work --workspace cloud` dropped its flag, and the next command carried on from a different source and worked, so the mistake surfaced one command later with nothing connecting it to its cause. A command that refuses cannot be wrong quietly. `instance.default_target` and `default_profile` are still parsed and no longer read. They stay declared so `check` and `doctor` can tell you the line in front of you is inert, rather than the schema dropping it and leaving you to believe it still selects something. Three commands take no `--workspace`, and that is not an oversight. `check`, `config show` and `policy list` are workspace-independent. `workspace list` is the command you run to find out what to pass, so requiring the answer as input would be circular. Typing it every time is the cost. A shell alias is the way to shorten it, and it is yours to write, this is the one place the tool declines to remember something on your behalf. ## Permissions ```console $ lanes link policy list --profile personal Allow + example.echo example.main + example.get_note example.main Deny A deny beats any allow, whatever the order in the file. - example.echo example.main2 $ lanes link policy allow example.list_notes example.main --profile personal --workspace local $ lanes link policy deny gmail.send gmail.main --profile personal --workspace local ``` Tightening is local and instant. **Widening a vendor scope needs browser re-consent** and goes through `lanes link connect <connection> --add <bundle>`. That asymmetry is inherent to OAuth. A rule naming an unknown connection is refused at write time, because a rule that silently grants nothing looks identical to a working one until someone relies on it. ## Your own context Memory, tasks, assets, skills, and the vault are providers like any other, but they hold your material rather than an account, so a profile arrives with all six declared and granted and there is nothing to connect ([ADR-050](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/050-the-owner-layer-is-granted-by-default.md)). Each has a control plane of its own, reaching the same bytes an agent does. Which store a thing goes in: **memory is what is true, tasks is what is to be done, assets is a file.** Nothing refuses the wrong choice, which is why the rule is stated to agents as well as here. [ADR-051](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/051-tasks-and-assets-are-their-own-stores.md). ```console $ printf 'The deploy window is Thursday evening.' \ | lanes link memory write deploy-window --title "Deploy window" --tag ops $ lanes link memory list --profile personal --workspace local --tag ops $ lanes link memory get deploy-window --profile personal --workspace local $ lanes link memory forget deploy-window --profile personal --workspace local ``` An entry is one Markdown file with YAML frontmatter, so a text editor is an equally good client: edit it in place and the next `memory.get` returns what you wrote. A file with no frontmatter at all is an entry titled after its id. ```console $ lanes link skills add review-diff --profile personal --workspace local --file review-diff.md # or the document on stdin $ lanes link skills list --profile personal --workspace local $ lanes link skills show review-diff --profile personal --workspace local $ lanes link skills remove review-diff --profile personal --workspace local ``` A skill becomes the MCP prompt `skills_<name>`. A running endpoint picks up a new one within a few seconds, with no restart. Skills belong to the profile they were added under and no other sees them, so `--profile work` is worth being deliberate about here. Agents can author skills too, under `skills.manage.*`, which is **not** in the default bundle; the `skills.*` rule a profile is created with grants it anyway, so narrowing it is one line: ```console $ lanes link policy deny skills.manage.* --profile personal --workspace local ``` ```console $ printf %s "$GITHUB_PAT" | lanes link vault set github_token --description "GitHub PAT" $ lanes link vault list --profile personal --workspace local # names and descriptions, never values $ lanes link vault get github_token --profile personal --workspace local --show $ TOKEN="$(lanes link vault get github_token --raw)" $ lanes link vault remove github_token --profile personal --workspace local --yes ``` `lanes link vault get` prints a value; `lanes link secrets` never does. Those are the two kinds of secret ([`security.md`](/docs/link/security)): a credential authorises the system, and a vault item is yours. A new item is **not readable over MCP until the endpoint restarts**, and it needs a grant naming it: ```console $ lanes link policy allow vault.get.github_token --profile personal --workspace local $ lanes link start --profile personal --workspace local # the item's capability exists from here on ``` That is deliberate: a write cannot hand itself a read, so granting access to a new secret is something you do between two runs rather than something an agent does mid-session. ## Who you are Names, addresses and handles to write as you, per profile. Optional, because nothing needs them until something writes as you and gets it wrong. ```console $ lanes link identity add name "A. Lovelace" --note "use on anything published" --profile personal --workspace local $ lanes link identity add name Ada --note "use for open-source work" --profile personal --workspace local $ lanes link identity add email ada.lovelace@example.com --profile personal --workspace local $ lanes link identity add github octocat --profile personal --workspace local $ lanes link identity list --profile personal $ lanes link identity remove name Ada --profile personal --workspace local ``` `identity list` takes no `--workspace`, for the reason `policy list` does not: the block is declared once in the YAML and applies to every workspace the profile has. `add` and `remove` publish the edit, so they name both. `kind` is the first argument and is yours to choose: `name`, `email` and `github` are conventions, not a list this project ships, so `linkedin`, `phone` or `pronouns` work with no code change. The note is what makes several of a kind usable, since it is read by whatever is deciding which one to use. Order is the ranking, so the first of a kind is the default. `identity` is the one owner-layer surface a profile does *not* arrive with, and that is deliberate: a profile declaring no identity has nothing for the surface to report. The first `identity add` writes the connection row and the `identity.*` grant for you, and says so. ```console $ lanes link identity add name "A. Lovelace" --profile personal --workspace local ok name A. Lovelace connections += identity.main policy.allow += identity.* an agent can now read this profile’s identity ``` Both of those are needed before anything can read the block, because a provider with no connection row is filtered out before policy is consulted, so `identity list` warns when a hand-edited profile has the entries and not the grant. Editing is CLI-only, deliberately: an agent able to change this could change the one fact that stops it signing as the wrong person. What it gets is one read-only tool, `identity_list`, and an instruction to call it rather than infer. Nothing here belongs to a connection. An entry says *when* it applies in prose rather than naming an account, so renaming a mailbox cannot break a profile. ## Who everyone else is The mirror of the block above: the people, companies and projects you deal with, so an agent looks an address up instead of recalling one from a thread. ```console $ lanes link entities write "Acme B.V." --name acme-bv --type company \ --attr domain=acme.test --profile personal --workspace local $ printf 'Prefers email over calls.' | lanes link entities write "Jan Bakker" \ --type person --alias Jan --attr email=jan@acme.test --attr github=janb \ --related works_at=acme-bv --profile personal --workspace local $ lanes link entities find Jan --profile personal --workspace local $ lanes link entities find --related works_at=acme-bv --profile personal --workspace local $ lanes link entities get acme-bv --profile personal --workspace local ``` That last one prints `← works_at Jan Bakker` even though Acme's own file never mentions him. An edge is written once, on the entity that declares it, and the reverse is derived. An entity is one Markdown file with frontmatter, the way a memory entry is, so a text editor is again a perfectly good client. Beside them sits a derived `_index.json`, a lookup cache stamped with a fingerprint of the files: edit one by hand and the next read rebuilds. `lanes link entities reindex` does it on demand after a bulk edit. Looking somebody up returns everything that matches, and more than one is a normal answer rather than an error. The reply shows what tells the candidates apart and says the order is not a ranking, because the one thing this exists for is that nothing silently picks between two people called Jan. ## Gate order Failures surface in the cheapest place first: ```console $ lanes link check --profile personal # static: schema, validation rules, no external calls $ lanes link doctor --profile personal --workspace local # external: credentials still authenticate, database reachable $ lanes link auth --profile personal --workspace local # just the credentials, per connection, as JSON $ lanes link plan --profile personal --workspace local # what reconcile would change; no mutation $ lanes link start --profile personal --workspace local # apply reconcile, then serve locally $ lanes link deploy --profile personal --workspace local # apply to the cloud workspace ``` `lanes link plan` exists specifically because reconcile disables undeclared connections, and that outcome should never be a surprise. ## Deploying The same config runs in more than one place; a workspace names an adapter set, and only the adapters differ. [Deploying to Cloud Run](/docs/link/deployment-cloudrun) is the full guide. The shape is: ```console $ lanes link deploy --profile personal --workspace local --dry-run # every gcloud command, none of them run $ lanes link deploy --profile personal --workspace local # set up, build, push, roll a revision $ lanes link connect gmail --profile personal --workspace cloud # a browser consent per account $ lanes link deploy --profile personal --workspace local # again, so the revision sees them $ lanes link outputs --profile personal --workspace cloud # the deployed URL an agent needs ``` `deploy` needs no `--workspace` when there is one deployment to mean: it deploys the workspace that has one, creates `cloud` when none does, and *asks* rather than guessing when several do. Naming one is how you deploy a second. See below. The first `deploy` writes the workspace if there is not one, and creates the project resources it names. `connect` comes after it rather than before, because a credential store it has not created yet is not somewhere to write a credential, and the second `deploy` is what gets a revision to reconcile the accounts you just authorised. `lanes link secrets push --from local --to cloud` migrates a setup you already built locally, instead of the `connect` step. ### More than one deployment `cloud` is a workspace name rather than a keyword. Nothing in the code reserves it, and a profile may declare as many deployable workspaces as you like. The second one is named on the deploy that creates it: ```console $ lanes link deploy --profile personal --workspace staging # surveys and writes workspaces.staging, then rolls it $ lanes link workspace list --profile personal # what this profile declares, and which is in play $ lanes link connect gmail --profile personal --workspace staging $ lanes link outputs --profile personal --workspace staging ``` Once two workspaces declare a deployment, a bare `lanes link deploy` refuses and asks which you meant, rolling a revision to whichever came first in a YAML mapping is the one answer that cannot be right on purpose. Each workspace has its own credential store, so a connection authorised against one is absent from the other. `lanes link secrets push --from cloud --to staging` copies them instead of re-running every consent. ### Which profiles a deploy uploads One: the profile you named. `deploy` requires `--profile`, so the flag *is* the resolved profile and the scope is never in doubt. It used to depend on how the profile had been resolved: the flag uploaded one, a variable or a config default uploaded the whole workspace, which was documented as surprising because it was. A deploy also refuses if a profile it would upload does not declare the workspace being deployed. The endpoint opens every profile in the bucket against one workspace, so one that cannot run there is not skipped at boot. It is a revision that never goes healthy. No credential ever travels. `data/` is never uploaded and each workspace has its own store, so a connection you have not migrated reconciles as `unauthorized` rather than silently working. ```console $ lanes link secrets list --profile personal --workspace local # reference names only; no command prints a value $ lanes link secrets push --profile personal --from local --to cloud ``` Credentials follow the workspace, because each workspace has its own credential store. `secrets push` copies and never deletes, and skips a reference the destination already holds unless you pass `--overwrite`, because the deployed copy may be the newer one. ## The desktop app Everything on this page has a window version. The [Lanes desktop app](https://lanes.sh/desktop) has a Lanes Link page under **Settings → Integrations → Lanes Link**: it holds the profile and workspace every command runs against, connects accounts, starts and stops the endpoint, and registers it with Claude Code or Codex. ```console $ lanes link desktop # opens the app on that page $ lanes link desktop --print # prints the link instead ``` You do not have to install Lanes first. If nothing on the machine answers a `lanes://` link, the command says so, offers `brew install --cask lanes-sh/lanes/lanes`, and opens the app once it lands. It asks before installing, and `--yes` answers ahead of time. `lanes link dashboard` is the same command. It used to open a page this endpoint served, and [ADR-053](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/053-the-page-a-person-reads-is-the-app.md) retired that page in favour of the app. Neither spelling takes `--profile` or `--workspace` any more; the app holds its own selection. It **runs** these commands rather than reimplementing them, which is the part worth knowing. Consent and the token stay here, an endpoint set up in the app is the same one you get from a shell, and anything the app cannot do yet is a command in this document. macOS only, as the app is; the CLI runs anywhere. **[How to use it →](https://lanes.sh/docs/desktop/lanes-link)** ## Inspection ```console $ lanes link status --profile personal --workspace local # connections, reachable capabilities, endpoint $ lanes link audit tail --profile personal --workspace local --limit 25 $ lanes link audit tail --profile personal --workspace local --denied-only # the interesting half $ lanes link config show --profile personal # the resolved config as JSON $ lanes link token show --profile personal --workspace local --show $ lanes link token rotate --profile personal --workspace local # invalidates every agent on this profile $ lanes link version # which release this is $ lanes link update --check # is a newer one published (exit 1 if so) $ lanes link update # install it ``` `update` re-runs the global install, because that is all an update is: there is no build step, so the `src/` inside the installed package is the code that runs. Bun is the only installer it drives. From a checkout it refuses and says so, because `git pull` is the update there. A running endpoint keeps serving the old code until it is restarted. `doctor`, `start`, and `deploy` each print one line when a newer release is out, and nothing when the registry cannot be reached. `lanes link audit tail` shows both allowed and denied calls, with arguments redacted per the provider's rules: ``` 10:44:30 allow example.echo example.main 1ms {"message":"hello"} 10:44:30 deny example.set_note 0ms {} ``` ## Flags `--json`, `--quiet` and `--help` are accepted everywhere. Everything else belongs to the command that reads it, and [`commands.md`](/docs/link/commands) lists them per command. `--profile` and `--workspace` are not overrides: nothing else selects either one, so there is nothing for them to override (ADR-037). The block that used to be here said they overrode `LANES_LINK_PROFILE` and `instance.default_target`, which the Selection section above has been contradicting since those stopped being read. A command that reads a value takes it on **stdin**, never on argv, so it does not land in shell history: `lanes link memory write`, `lanes link skills add`, `lanes link vault set`, and `lanes link secrets set` all work this way and refuse rather than hang when stdin is a terminal. `lanes link tasks add` reads its notes the same way but does not refuse, because they are optional: the title is the argument. Source: https://lanes.sh/docs/link/workflow --- # Architecture > How it fits together, and the dispatch path a call takes Lanes Link is a self-hostable MCP endpoint that exposes a person's accounts, knowledge, procedures, and secrets to any agent, behind an authorization boundary the runtime enforces. The bet: `gmail.search = allow` and `gmail.send = deny` are decisions the runtime **enforces**, not instructions the model is asked to respect. Everything below exists to make that answer binding. ## The component types | Type | Backed by | MCP shape | Control plane | |---|---|---|---| | **Connections** | a third-party vendor | tools | `lanes link connect` | | **Memory** | `BlobStore`, one Markdown file per entry | resources + search | `lanes link memory` | | **Tasks** | `BlobStore`, one Markdown file per task | resources + tools | `lanes link tasks` | | **Assets** | `BlobStore`, the file under its own name | resources + tools | `lanes link assets` | | **Skills** | `BlobStore`, one Markdown file per skill | prompts, plus `skills.manage.*` | `lanes link skills` | | **Vault** | one encrypted document, its own key | tools, tightly scoped | `lanes link vault` | Every one of them is a **provider** behind the same policy layer, audit log, profile boundary, and endpoint, and since [ADR-030](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/030-a-profile-owns-its-skills-and-manifests.md) the profile boundary holds for all of them rather than only the credentialed ones, because a skill is no longer a workspace-wide file. `memory.search = allow` is evaluated by the identical code path as `gmail.search`. The core cannot tell them apart and must not try. Two families, structurally identical to the core: - **Account providers** (`gmail`, `notion`): external, credentialed, tool-shaped - **Owner providers** (`memory`, `tasks`, `assets`, `skills`, `vault`, `example`): local, owner-authored, no external credential, resource- and prompt-shaped The owner providers are also the ones a profile arrives with already granted ([ADR-050](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/050-the-owner-layer-is-granted-by-default.md)). That is not an exception to the policy layer: the rules are written into the profile like any others, a `deny` still beats them, and the evaluation is the same code. What makes it defensible is that none of them reaches an account, which is what default deny is for. The three that hold what the owner keeps divide by what a thing *is*, not by size ([ADR-051](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/051-tasks-and-assets-are-their-own-stores.md)): memory is what is true, tasks is what is to be done, assets is a file. Memory has no status field because a fact does not finish, which is the property that made a second store necessary rather than a wider first one. `example` is an owner provider in miniature, which is why it earns its place beyond being an SDK sample: it proves that shape before the owner layer exists. ## Domain model - **Profile**, a named grouping. **One profile = one config = one database = one credential store.** Profiles share an endpoint and its token ([ADR-009](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/009-one-endpoint-per-workspace.md)); each call names the profile it acts within. Profiles never share a database, a credential store, or a URL. An operator wanting several available to one agent runs several endpoints, which costs nothing and keeps a compromise of one from reaching another. - **Provider**, a *type* of capability source. - **Connection**, one configured *instance* of a provider. `gmail.main`, `memory.work`. (Widened from init.md's "one configured account" so owner providers fit the same noun.) - **Capability**, a tool, resource, or prompt a provider exposes. Addressed `provider.name`. - **Policy**, one block per profile. Default deny. - **AuditEvent**, append-only, generic across providers, redacted per provider. ## Layout One package, one `src/`, thirteen components named for the question each answers. Cross-component imports go through the package.json `imports` map: `#policy`, `#stores/state`, `#providers/google/gmail`. ``` src/cli/ lanes link, the control plane src/server/ Bun.serve, bearer auth, and the MCP surface it wraps src/profile/ what a profile is: schema, loading, workspace resolution, layout src/registry/ what exists and what it is called: providers, capabilities, reconcile src/dispatch/ how one call runs: context, then policy → limits → provider → audit src/policy/ rule evaluation, floor composition, rate limits src/audit/ event shape, the sink and reader contracts, the chain, redaction rules src/auth/ endpoint identity: token → principal src/stores/ the BlobStore contract and the runtime state built on it (state and the log are both objects in it: ADR-020) src/secrets/ one encrypted-document format, two stores: system and vault src/connectivity/ what a provider declares: transports/, auth/, and mail/ src/providers/ every provider, one folder each, holding all its vendor knowledge src/deployments/ where this runs: protocol-named adapters, vendor-named deployments ``` Dependencies run one way: storage contracts → connectivity → registry/dispatch → server/cli, with `deployments` reached only by `profile`. Nothing above imports a backend directly. **That direction is asserted by `src/architecture.test.ts`**, along with two other rules the layout is meant to express: no vendor name in the code a request passes through, and a file-size budget with an explicit list of what is still over it. Thirteen `package.json` files used to enforce the first of those structurally; one package does not, so the test does, at file granularity, which is stricter than the package graph was. **No component depends on a client library for its backend.** `Bun.S3Client` is a built-in, so `s3.ts` reaches its store without adding a dependency to a repository that holds live refresh tokens; Secret Manager, GCS, and the OTLP sink are `fetch` against REST APIs for the same reason. The cost is that those adapters, along with `src/server/index.ts` and the CLI, are where Bun-specific API use is concentrated, so a port to Node would rewrite them and little else. **There is no database.** `bun:sqlite` and `Bun.SQL` used to head that list. Every access was a point read, a point write, or one prefix listing, so the tables were carrying a query engine nothing queried. State is now one object per key and the audit log one object per event, both in the `BlobStore` the workspace already opens (ADR-020, ADR-021). Deployed, the standing dependencies are one bucket and Secret Manager. ## The dispatch path Every invocation runs this sequence, with no way around it: ``` Bun.serve → auth bearer token → principal, constant-time compare. Unknown → 401. → discovery server built as a pure function of resolved policy, memoised → policy (principal, capability, connection). Default deny, tighten-only. → limits per-profile and per-connection token buckets → dispatch provider receives a ProviderContext, never a raw backend → audit one event, on every path, including denials ``` The ordering is not stylistic. **Policy is evaluated before a provider is reached**, so a provider never sees a request it was not authorised to serve, and authorization is never something provider code could get wrong. A test asserts the handler is not called on a denial. **Exactly one audit event per invocation**, enforced by `finally` rather than by remembering to call it at each return. See `/docs/link/security` for the one documented exception. ## Policy Default deny: an empty policy grants nothing. Composition is **tighten-only**, so the optional instance floor is evaluated first and its denial is final, so no arrangement of profile rules can widen past it. The floor is empty in M1; the invariant is implemented anyway, because it is what makes delegated access safe to add later and it cannot be retrofitted once rules exist in the wild. **A deny beats an allow regardless of order in the file.** Rule ordering cannot change the answer, so a denial is never something you can accidentally out-rank. Wildcards are a trailing `.*` on `capability` only. Connections are never wildcarded. There is deliberately no policy expression language: every additional operator is another way for an operator to believe they wrote something narrower than they did. ## Discovery filtering A capability the principal cannot reach on any connection **is not registered at all**, not registered and refused on call. The `connection` argument's enum is built from resolved policy, so a client cannot discover a connection it has no grant for. Discovery filtering and invocation enforcement share one implementation (`allowedConnections`, which calls the same `evaluate` the dispatcher uses). Computing them separately would let them drift, and a leak in discovery is still a leak. Under `2026-07-28` this covers **`server/discover`** as well as `tools/list`, so it is a second discovery surface, and a filter applied to only one of them is not a filter. ## Isolation | Boundary | Mechanism | |---|---| | Provider ↔ provider state | `ScopedStore` namespaced `<provider>/<connection>` | | Connection ↔ connection state | same namespace | | Connection ↔ credentials | `ScopedCredentials` over an explicit allowlist | | Provider ↔ blobs | `scopeBlobStore`, with traversal rejected rather than rewritten | | Profile ↔ profile | separate database, credential store, port, and token | Every one is enforced in the wrapper rather than trusted to the provider. An out-of-scope credential ref fails identically to a missing one, so the error cannot enumerate the store. ## Configuration and reconcile The config file says what exists; the credential store holds values; the database holds only runtime state. Reconcile on boot upserts declared entities, marks undeclared connections **disabled rather than deleted** (preserving audit history), marks a connection with a missing credential `unauthorized` **without blocking startup**, and reports drift in both directions. `lanes link plan` and `lanes link start` compute the same plan through the same function, so the preview cannot become a lie. See [ADR-004](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/004-declarative-config.md). ## The owner layer The capability namespaces `memory.*`, `tasks.*`, `assets.*`, `skills.*`, `vault.*` and `entities.*` are **still refused at registration**. Only the built-in registry opts in, because reclaiming a namespace once providers exist in the wild would silently change what a policy rule means. `tasks` cost something to reserve: Google Tasks held that id and was renamed `google_tasks`, because a manifest already registered under it would have thrown on the second registration rather than shadowing anything ([ADR-051](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/051-tasks-and-assets-are-their-own-stores.md)). Everything else that was reserved for it is now in use. The MCP prompts primitive carries `skills`. `BlobStore` has its first consumer in `memory`, which is the workload the interface was defined for before any provider existed. The resource primitive finally has a runtime path at all: it was declared in M1 and unreachable until the owner layer needed it, which is recorded in [ADR-012](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/012-owner-layer-primitives.md) rather than quietly fixed. Source: https://lanes.sh/docs/link/architecture --- # Security model > The threat model and the guarantee table This system holds live credentials to the owner's email and documents. This document states its limits honestly rather than implying guarantees the code does not deliver. To report a vulnerability, see [`SECURITY.md`](https://github.com/lanes-sh/link/blob/main/SECURITY.md). Please do not open a public issue. ## Protected assets OAuth app secrets, connection refresh tokens and app passwords, the profile bearer token, the content returned through capabilities (message bodies, documents), audit records, and the configuration itself. ## Goals Prevent a caller from reaching a connection it holds no grant for. Keep each connection's credential reachable only by its own provider invocation. Make every invocation attributable. Keep control-plane decisions outside agent reach. Lanes Link does **not** attempt to guarantee correct agent behaviour, continuous availability, or that a model will not disclose data it was legitimately given. ## Trust boundaries - **The owner controls** the machine or cloud project, the blob store, the credential store, the encryption key, and the config file. Lanes Link does not protect a deployment from a compromised owner environment. - **Provider code is trusted code.** It runs in-process with core and holds its connection's credential. Installing a third-party provider is equivalent to running arbitrary code with access to that account. **There is no provider sandbox.** - **Clients are authenticated but untrusted in intent.** A client may attempt any capability; the policy layer decides. Compromise of the profile token grants exactly that profile's grants. - **Content returned from upstream accounts is untrusted data.** An email body or document may contain prompt injection aimed at the consuming agent. Lanes Link passes content through and does not screen it. Screening, if ever added, belongs in an optional module. - **Upstream vendors** receive the requests made on the owner's behalf, subject to their own retention policies. ## The two kinds of secret This distinction is the most important one in the codebase, and collapsing it would be the most damaging single mistake available. | | **Credentials** (`SecretStore`) | **Vault items** (M3) | |---|---|---| | What | refresh tokens, app-specific passwords, pasted API tokens, the profile token, and an OAuth client secret where the operator registered one of their own | the owner's own passwords, API keys | | Authorises | the system itself | nothing, because they are data the owner stores | | Agent-reachable | **never, in any form** | yes, under policy, default deny | | Store | encrypted file, its own key | separate store, **separate key** | **One thing is a credential and does not live in that store**, and it is worth naming rather than leaving to be discovered. An `auth: { kind: strategy }` provider may hold a vendor-issued *session*, and bunq's lives in scoped runtime state, the blob store on a deployed workspace, not in `SecretStore`. That is not a preference: `AuthStrategyContext.write` exists only during `connect`, and a deployed revision is granted write access on nothing for a non-OAuth provider, so per-request code cannot put one there. It is acceptable because of what such a token is: short-lived, revocable by the owner from the vendor's own app, and reconstructible from the durable credential, which does sit in `SecretStore`. It is still a bearer token in a weaker store than the material beside it, and [ADR-046](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/046-an-auth-strategy-belongs-to-its-provider.md) argues the trade in full. It is not agent-reachable either way. If an agent could read the Gmail refresh token it would simply call Google directly, and the entire policy layer would become decorative. That is why the infrastructure interface is called `SecretStore` rather than "secrets", why `Vault` will be a separate provider over a separate store, and why a test asserting **Vault can never reach SecretStore** exists in M1, before the vault does. ## What the controls do and do not guarantee Policy evaluation, scoped state, scoped credentials, and audit are designed to reduce cross-connection access and make actions attributable. They are **not** a proof of non-interference. Audit records support investigation; they do not prevent an action. Encryption at rest protects stored material from direct reads, **not plaintext in a running process**. A credential is in memory whenever a provider makes a call, and that limit is inherent. Rate limits blunt runaway loops; they are not a security boundary. ## Guarantee status `ENFORCED` means code rejects or tests the property today. `NOT-GUARANTEED` names a documented limitation. `RESERVED` names a compatibility slot with no implementation. | Property | Status | Verifier | |---|---|---| | `policy.default-deny` | ENFORCED | `src/policy/index.test.ts` | | `policy.tighten-only` | ENFORCED | policy composition tests | | `policy.deny-beats-allow` | ENFORCED | asserted in both rule orders | | `control-plane.not-agent-reachable` | ENFORCED | `src/dispatch/control-plane.test.ts` | | `config.contract-major-fails-closed` | ENFORCED | loader tests | | `config.no-secret-values` | ENFORCED | validator tests with credential fixtures | | `credentials.connection-scoped` | ENFORCED | `ScopedCredentials` isolation tests | | `credentials.not-agent-reachable` | ENFORCED | context surface assertion | | `state.provider-scoped` | ENFORCED | `ScopedStore` isolation tests | | `storage.namespace-contained` | ENFORCED | traversal rejection tests | | `deployed.config-not-self-writable` | ENFORCED (deployed workspace) | the revision's `objectAdmin` grant is conditioned on the prefixes it owns, so `profiles/`, `lanes-link.yaml` and each profile's `providers.d/` are readable and not writable. The last of those sits *inside* the granted `data/` prefix since [ADR-030](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/030-a-profile-owns-its-skills-and-manifests.md), so the condition carries an explicit exclusion rather than simply not naming it. Enforced by the platform; `src/deployments/grants.test.ts` evaluates the shipped expression, rather than scanning for prefixes, which would read straight past a negation, and asserts the keys the endpoint writes fall inside it and the config paths fall outside. This replaces the read-only image that carried the guarantee before [ADR-023](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/023-the-workspace-is-not-in-the-image.md) | | `knowledge.excludes-secrets` | ENFORCED | a workspace may keep memory, skills and entity files in a GitHub repository ([ADR-041](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/041-memory-and-skills-in-a-repository.md), amended by [ADR-056](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/056-everyone-else-is-declared-too.md)); the credential store, the vault, runtime state, the audit log, tasks and assets are excluded **structurally**, because `knowledgeTargetSchema` has no field that could name any of them, so no flag, no override, and no copied example can move one. The amendment adds a third *document* area and no field, which is the property this row asserts. `src/profile/load.test.ts` asserts that `credentials:` and `vault:` written into the block are stripped rather than honoured, and `src/cli/runtime/knowledge.test.ts` asserts both that everything which is not a document area stays on the workspace's own storage and that the schema's field list is exactly the five it has always been | | `owner-layer.granted-by-default` | **WEAKENED deliberately, and only for providers with no account** | a new profile is created with `memory.*`, `tasks.*`, `assets.*`, `skills.*`, `vault.*` and `setup.*` allowed, and an existing one is repaired on the next `start`, `connect` or `deploy` ([ADR-050](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/050-the-owner-layer-is-granted-by-default.md)). Default deny still holds for everything it is for: none of these reaches a third-party account, none has a credential or an OAuth app, and each is empty until the owner puts something in it. The grant is the one `lanes link connect memory` already wrote, so nothing is newly expressible. See the owner-layer section below for the write half, and `deny` for the way off. `src/cli/config-edit.test.ts` asserts the template and the repair write one spelling, and that a `deny` covering a surface is never undone | | `audit.append-only` | ENFORCED | the store interface has no update or delete | | `audit.tamper-evident` | ENFORCED **for edits and mid-run removals** | records are hash-chained per run; `lanes link audit verify`. Truncating a run killed mid-write, or deleting a run whole, is not detectable. See [ADR-020](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/020-the-log-is-objects.md) | | `audit.redaction` | ENFORCED | provider redaction tests, including on denials | | `discovery.policy-filtered` | ENFORCED | `tools/list` and `server/discover`, over the wire | | `setup.reports-only-reachable` | ENFORCED | `src/server/setup-surface.test.ts`; a denied connection reads as one never made | | `setup.no-credential-presence` | ENFORCED | `missingRequirements` is CLI-only; the surface reports requirements, not what is stored | | `transport.stateless` | ENFORCED | restart-mid-session test | | `credentials.encrypted-at-rest` | ENFORCED (file adapter) | nothing readable on disk; tamper detection | | `profile.isolated` | ENFORCED | cross-profile token, state, and audit tests, plus `src/cli/runtime/scoping.test.ts` for the owner layer. Two things were shared until [ADR-030](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/030-a-profile-owns-its-skills-and-manifests.md), namely skills and provider manifests, both at the workspace root, so this row was previously true of credentials, state and the log rather than of everything a profile holds | | `oauth.refresh-replay-is-refused-and-recorded` | ENFORCED **outside a 30-second reuse interval** | a spent refresh token is tombstoned rather than deleted, so presenting it again is detectable; within 30 seconds of being spent it still answers, because a client whose refresh response was lost has no other move and the reference client rethrows `invalid_grant` rather than recovering ([ADR-036](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/036-a-client-is-told-this-endpoint-keeps-it-signed-in.md) has the client-side reasoning). After that the replayed token is refused, the replay is logged, and the family it belongs to keeps working, tested over real HTTP in `src/server/oauth.test.ts` from any depth in the chain. Revoking the whole family instead is what [ADR-035](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/035-a-replayed-refresh-token-must-not-log-the-owner-out.md) reversed, and what it gives up is stated there: a family is minted once and never rotates, so the old answer logged an approved client out roughly daily and a thief never | | `token.rotation-takes-effect` | ENFORCED **within a five-second window** | `src/auth/index.test.ts` covers both halves against a real credential store: the replacement is accepted on its first call, and the rotated-away token stops working once the window passes. Both caches are dropped together, the authenticator's and the credential store's, because dropping only one re-reads the same stale value | | `limits.per-profile` | ENFORCED per instance | rate limit tests | | `edge.pre-auth-metered` | ENFORCED (deployed workspace) | the four things that answer before the bearer check, namely `/health` presented with a credential, `/register`, `/authorize` and `/token`, each cost a credential-store read or a bucket write, and until [ADR-054](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/054-the-surface-in-front-of-the-gate.md) none of them passed through any ceiling: the limit lived inside the `401` branch, which is reached after the 404 gate. Two buckets are now taken on each, one keyed on the caller and one keyed on nothing, and the second is the one that holds, because the caller key is the first `X-Forwarded-For` hop and a stranger writes that. `/health` with **no** credential stays free: it reads nothing, and it is what a platform probe sends. Off on loopback, decided beside CORS and the dashboard in `serve()`, for the reason ADR-054 gives. `src/server/index.test.ts` drives it over real HTTP | | `limits.map-is-bounded` | ENFORCED | a rate-limiter key is the caller's forwarded address, which on a public URL is a header a stranger writes. `RateLimiter` evicts, idle buckets first and then oldest-by-last-use in batches, rather than relying on a `prune` a caller remembers to call, which for most of this file's life nothing did. `src/policy/limits.test.ts` | | `deployed.data-recoverable` | ENFORCED (deployed workspace) | the revision holds `objectAdmin` on `data/`, which contains `storage.objects.delete`, so the process most exposed to the internet can erase the record of what it did. The bucket carries a 30-day soft-delete window and object versioning with a lifecycle rule bounding it, applied by `buckets update` on **every** deploy rather than as flags on the create, which is refused as `ALREADY_EXISTS` from the second deploy onwards and so would never reach a bucket that already exists. This does not make deletion *detectable*, as `audit.tamper-evident` covers, but it makes it undoable | | `deployed.not-publicly-shareable` | ENFORCED (deployed workspace) | public access prevention is enforced on the bucket, so anonymous read cannot be granted rather than merely not being granted. Uniform bucket-level access already removed per-object ACLs; this removes the bucket-level route to the same place | | `audit.every-invocation` | ENFORCED **with two documented exceptions** | see below | | `credentials.client-secret-never-local` | ENFORCED (hosted client) | there is no client secret on the machine to hold. `resolveSecretRefs` grants no client reference at all for a connection authorised this way, asserted in `src/dispatch/context.test.ts` | | `credentials.exchange-is-local` | **NOT-GUARANTEED for a connection authorised against the hosted client** | see below | | `credentials.no-standing-grant` | **NOT-GUARANTEED for a connection authenticated with a key** | see below | | `credentials.plaintext-in-use` | NOT-GUARANTEED | inherent | | `provider.sandboxed` | NOT-GUARANTEED | provider code is trusted | | `provider.response-may-carry-a-credential` | NOT-GUARANTEED **for two Discord operations** | a capability's *response* is returned to the caller unread, and `discord.create_webhook` and `discord.list_channel_webhooks` include the webhook's token in theirs. A webhook token is standalone: it posts to that one channel with no other authentication. Accepted in [ADR-047](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/047-a-pasted-token-carries-its-own-scheme.md) because the alternative is not the same capability made safe but no posting under the operator's own name at all. Bounded to one channel, withheld from the audit log by `DISCORD_REDACT` and asserted so in `src/providers/discord/discord.test.ts`, and revocable from Discord's channel settings. See [Discord](/docs/link/discord). Distinct from `credentials.not-agent-reachable`, which is about this system's own store and still holds | | `egress.controlled` | NOT-GUARANTEED | follows from the above | | `policy.approval_required` | RESERVED | the model carries the state; no engine, and it fails closed | | `delegation.external-clients` | RESERVED | the principal parameter; nothing more | ### What `credentials.no-standing-grant` gives up [ADR-038](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/038-a-key-is-the-second-way-into-an-account.md) added a second way to authenticate a Google connection: a service account key, signed into a short-lived assertion and exchanged for an access token (RFC 7523). It is the only route here where nothing expires, and that is the same sentence read two ways. Every other credential this system holds decays or can be withdrawn from the other end. An OAuth refresh token can be revoked from a Google account page, is subject to the issuer's own expiry policy, and dies with the consent that produced it. An app-specific password dies when the account password changes. A key does none of that: **nobody consented, so there is no consent to withdraw, and a leaked key stays valid until somebody deletes it in a console.** What bounds it instead is reach, and the bound is real: - Without domain-wide delegation the key is an identity of its own, and reaches **only what has been shared with its address**. Nothing in the account moves until somebody shares it, and a narrower standing grant than any OAuth token here, not a wider one. - With domain-wide delegation the key may act as any user in the domain, for the scopes an administrator listed. That is the wide case, and it is granted by an administrator in their own console rather than by anything in this repository. The key is stored in whichever credential store the config names, encrypted at rest under the file adapter like every other secret, and is never sent anywhere except to the token endpoint named inside the key file itself. The minted access token is held in memory for the life of the process and never written back. See ADR-038 for why persisting it would have widened what a deployed revision is granted. Prefer sharing over delegation wherever sharing will do. One shared folder is a much smaller grant than the right to act as a person, and the two are one prompt apart. ### What `credentials.exchange-is-local` gives up Since [ADR-028](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/028-a-hosted-oauth-client-is-the-default.md) a Google connection authorises, by default, against an OAuth client Lanes operates rather than one the operator registers. That removes a nine-step console walkthrough. Since [ADR-040](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/040-an-mcp-connector-may-use-a-pre-registered-client.md) a Slack connection does the same, removing a six-step one. It also moves one step off this machine, and the honest statement of that is worth more than the convenience: - The **authorization code** is sent to the Lanes API, because redeeming it needs the client secret and that secret is deliberately not here. - The **refresh token** comes back through the Lanes API, and passes through it again on every later refresh. - An **identity assertion** (a Google `id_token`, obtained from the `openid` and `email` scopes the flow adds for this purpose) is sent with each refresh, so the API can attribute and rate-limit per account. It is stored beside the tokens and never decoded here. Everything else is unchanged: the browser still talks to the vendor directly, the redirect still lands on a loopback listener this process opened, the endpoint still never participates, and the tokens still live in whichever credential store the config names. Slack differs from Google in one way worth stating, and it is in Slack's favour. It issues no refresh token unless token rotation is enabled on the app, so only the first exchange goes through the Lanes API and nothing does afterwards. Where a Google connection passes a refresh token through it for as long as the connection lives. Slack's `/config` also asks for no identity scopes, because there is no `openid` here to attribute a refresh with and nothing to attribute. **Nothing in this repository can verify what the Lanes API does with what it sees.** That is the whole of the trade, and it is why this is a row in the table rather than a paragraph in a guide. An operator who does not want to make it picks "an OAuth client you register" at the connect prompt once per profile, or `--auth own_client`, or the older `--own-client`, which registers a client of their own and keeps the exchange between this machine and Google. A service account key keeps it local too, and for a different reason: there is no exchange to move, because there is no authorization code. Declaring `oauth_apps` in a profile is the same choice expressed in config, and a profile that declares it is never moved off it. ### Failed authentication is logged, not audited A refusal record needs a principal, and failing authentication is precisely not having one, so a rejected credential cannot be an audit row without inventing a caller to attribute it to. It goes to the endpoint's operational log instead: stderr for `lanes link start`, stdout for the container, where Cloud Run collects it. The line names the reason (`invalid`, `malformed`, `missing`, `not_configured`) and never the value presented. This is a change. The warning was written from the start and every caller passed a logger whose methods were empty, so on a public URL a sustained probe left no trace anywhere. ### The documented exceptions to `audit.every-invocation` Every call that reaches dispatch is audited, allowed or denied. A call naming a **capability that policy filtering hid** is also audited, via `Dispatcher.recordRefusal`. Two cases are not. **Not audited: arguments that fail schema validation.** A call to an advertised tool whose *arguments* are rejected, including a `connection` value outside the advertised enum. The protocol layer rejects it before dispatch runs. The caller gets a clear error naming the permitted options, and nothing is invoked, but no audit row is written. Closing it would mean either dropping the enum from the tool schema, which is what makes connections undiscoverable in the first place, or parsing the request body at the edge. Neither trade is currently worth it. **Not audited: a pre-envelope HTTP call to a hidden capability.** The refusal record for a policy-filtered capability is written at the HTTP edge, which identifies the call by reading `Mcp-Method` and `Mcp-Name`. The 2026-07-28 envelope requires both and rejects any request whose headers and body disagree, so for an envelope client the header read is exact. A 2025-era client sends neither header, and the endpoint still serves those requests, because `createMcpHandler` is constructed without a `legacy` option, and its default is `'stateless'`. So the refusal check short-circuits, the legacy leg answers `-32602 Tool … not found`, and no row is written. What that costs: an authenticated caller can enumerate which capabilities exist without leaving a refusal trace, by speaking the older protocol. It is not reachable unauthenticated, and nothing is invoked either way. Closing it means reading the body at the edge for requests that arrive without the headers, a `request.clone()` and a parse, which is precisely what `serveOverStdio` already does, because a pipe has no headers to read instead. The alternative, `legacy: 'reject'`, closes it by refusing every pre-envelope client, and several of the clients in [Add it to your agent](/docs/link/clients) have not moved. Both were found by end-to-end verification rather than reasoned about in advance, and both are recorded here rather than papered over. `src/server/index.test.ts` asserts the second one directly, next to the test that shows the envelope path recording the same probe. ## Upstream credentials The server is its own OAuth client to each vendor and **never forwards an incoming bearer token upstream**. A caller's token authenticates them to *this* endpoint and has no meaning at Google; forwarding it is the confused-deputy mistake. The separation is structural rather than a rule to remember: the caller's token never reaches provider code at all, and an integration test asserts that what Google sees is only ever derived from the connection's own refresh token. Access tokens are derived at runtime and **cached in memory only, never persisted**. They are short-lived by design, so storing one would create a second credential to protect for no benefit. The cache is keyed per connection, so two accounts never share a token, and a stateless server starts cold with an empty cache. **Not every upstream credential is an OAuth token, and the ones that are not are weaker in two ways.** iCloud takes an app-specific password; GitHub, Slack and Discord take a token the operator generates and pastes. The first two because neither vendor's MCP server will register a client for us (ADR-033), Discord because its bot token is a property of the application rather than anything an OAuth exchange returns (ADR-047). Such a credential is long-lived and *is* persisted, because there is no refresh, so the stored value is the credential itself rather than a means of obtaining one. Rotation is manual: `connect --replace`, after revoking upstream. The second difference is the one worth saying out loud. For an OAuth provider, `connect` shows what is about to be granted and refuses to proceed if the scopes have widened without being agreed, `confirmScopes` is that gate. There is no equivalent here, and there cannot be: what a pasted token can do is chosen in the vendor's own console, and this endpoint has no way to read it back. So the guarantee for these providers is narrower. The policy layer still bounds what an agent may *call*, but the credential's own reach is the operator's to bound, at the vendor, when they create it. All three setup pages say so at the point the token is generated. **A Google Cloud project left in "Testing" publishing status expires refresh tokens after seven days.** That is a policy setting, not a bug, but it presents as an authentication failure on a weekly schedule, so `invalid_grant` is detected specifically and the error names the cause. See [`setup/google.md`](/docs/link/google). ## Known limitations (M1) - **Bearer tokens are bearer authorization.** Anyone holding the profile token is the principal. Tokens are not bound to a device. Revocation means rotating the token and reconciling. A running endpoint notices a rotation within five seconds rather than instantly: the expected value is cached for that long so the common case is a comparison rather than a decrypt. The replacement works immediately: a token that does not match a cached value forces a re-read before it is rejected, which is what makes the rotated-in credential usable on its first call. - **Agent config files are a real exposure.** MCP client configuration often sits in plaintext on disk, so a token is roughly as protected as that file. - **One token per profile.** Two agents cannot hold different permissions against the same profile; they need separate profiles. Rotating re-authorises every agent on that profile. Audit attributes calls to the profile's principal, not to a specific agent, so the recorded `clientInfo` label is self-reported and never consulted for authorization. - **An authorised remote client is the owner.** Where `auth.authorization` is declared, a client that completed the flow resolves to the same owner principal the bearer token yields, so the scope it was granted is not a permission boundary; policy is. Revoking one client means deleting its tokens from the profile's state store; there is no per-client revocation command yet. See [ADR-018](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/018-the-gate-is-in-the-application.md). - **Anyone who can reach the endpoint can register a client.** Registration yields an identifier and nothing more: no client obtains a token without an approval performed by hand with the endpoint token. It does mean an unauthenticated caller can write rows, so the list is capped at 200 and the oldest without a live token are evicted, so a connector in use is never dropped to make room. - **Rate limits are per instance.** On a horizontally scaled deployment they are not global. What bounds the aggregate instead is `max_instances`, which the rollout now always sends and which defaults to four. With no ceiling on instances the aggregate had no value at all, and `/docs/link/deployment-cloudrun` told the reader to set one themselves because nothing did. Two ceilings sit at the HTTP edge. One is on *failed* authentication, and exists to bound the credential-store re-read a mismatch triggers rather than to make guessing harder; 256 bits already does that; only a failure spends it, so a valid token is never refused by it. The other is on the surface that answers *before* authentication, and is the subject of [ADR-054](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/054-the-surface-in-front-of-the-gate.md). Neither is a security boundary. - **No egress control**, no provider sandbox, no secret scanning on write. - **Content leaving the boundary is not recoverable.** Lanes Link governs what an agent may fetch, not what happens to it afterwards. ## Supply chain This project holds long-lived credentials, so dependency compromise is a live threat. `bunfig.toml` sets `minimumReleaseAge = 604800`, seven days. The common attack is to publish a compromised version and yank it within hours; a release-age floor keeps a version that young out of the lockfile entirely. This is verified rather than assumed: `bun add hono` resolves to the newest release older than the floor, not to `latest`. Also: `bun run audit` for lockfile CVEs, `bun install --frozen-lockfile` in CI, a pinned Bun version in `.bun-version`, and a committed lockfile. An urgent security fix can be pulled in ahead of the window by installing an exact version explicitly. **That line used to say `bun pm scan`, and `bun pm scan` did nothing.** Without an `[install.security] scanner` configured in `bunfig.toml` it prints how to configure one and exits zero, so the check was documented, scripted, and inert, on a project holding live OAuth refresh tokens. `bun audit` needs no scanner package: it resolves the lockfile against npm's advisory database directly. It runs in CI now rather than only in the script, because a control nobody has to act on is the state this replaces. The base image is pinned by digest as well as by tag. A tag is a pointer its publisher can move, so it is not a promise about bytes. The release-age floor above protects the npm half of the supply chain, and the base image is the larger half and had nothing. The runtime dependency set is deliberately small: MCP SDK v2 (`core` has one dependency), `zod`, and `yaml`. Argument parsing is hand-rolled rather than delegated, because a dependency that parses argv in a process holding credential-store keys is not worth the convenience. ## The owner layer's own risks Recorded before the layer was built, and kept here with what was done about each. **Writable memory is an injection persistence channel.** Upstream content is already treated as potentially prompt-injecting and passed through unscreened. Owner-authored memory that an agent can *write to* changes the risk: an injected instruction can be stored once and re-served to every future session, including to a different agent. Read-only memory does not have this property, which is the strongest argument for `memory.write` being a separate capability in a non-default bundle, which is what it is. **A written skill is the same risk, one turn earlier.** A skill is instructions an agent is later handed as its own turn, so an agent that can author one can shape what it does next. ADR-012 §1 first answered this by having no write path at all; [ADR-014](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/014-owner-layer-is-managed.md) replaced that with the same answer memory uses, `skills.manage.*` out of the default bundle, for a reason worth repeating here. Structural absence read stronger than it was: a skill file is writable by anything running as the owner, so "no agent can write a skill" only ever meant "not through the one path that evaluates policy and writes an audit event". Moving authoring inside that boundary made it governable. **Reading a skill is the narrower risk, and is still withheld by default.** `skills.manage.get` is in the author bundle, not the read one, so an agent that can invoke skills cannot browse them for instructions to give itself. **Neither of these screens anything.** They separate a privilege. Nothing in this codebase detects an injection, and no part of it claims to. **An entity is the same channel as memory, and it is the one that gets acted on.** [ADR-056](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/056-everyone-else-is-declared-too.md) adds a store whose whole purpose is to be read just before something is sent. An injected memory entry is re-served as something true and a model may or may not use it; an injected address is *used*, because it is what the next message is addressed to. It answers this the way memory does, with writing in a non-default bundle, and it takes three rules rather than one: `deny: [entities.write, entities.link, entities.forget]`. A second, quieter property is worth naming. `entities.find` returns every match and sets no error when there is more than one, so nothing in the protocol stops a client taking the first. What stops it is the shape of the answer, which states the count before any candidate and says the order is not a ranking, and the instruction the endpoint sends before the first call. That is a design choice rather than an enforcement, and it is recorded as one. **A task is the same channel as memory, and an asset is a smaller one.** [ADR-051](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/051-tasks-and-assets-are-their-own-stores.md) adds two more stores an agent can write to and that are re-served later. A task is memory's risk with a due date: an injected instruction that presents itself as something the owner asked for. Both answer it the same way memory does, with writing in a non-default bundle: `deny: [tasks.add, tasks.update, tasks.remove]`. An asset is narrower, because nothing reads one back as instructions unless it is text and something asks for it. But it is bytes the owner did not choose the size of, which is why `assets.store` carries the same ceiling and the same SHA-256 receipt a mail attachment does. **The profile a fresh install starts from grants more than the engine does.** A new profile is created with the whole owner layer allowed, write halves included, and an existing one is repaired on the next `start`, `connect` or `deploy` ([ADR-050](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/050-the-owner-layer-is-granted-by-default.md)). Default deny is true of the policy engine, where nothing is reachable without a rule, and not of the file the CLI writes for you. That is a deliberate weakening and it is stated as one in the table above; what bounds it is that every provider in the list holds the owner's own material and reaches no account. Narrowing is one `deny` line or a second profile, and the ones worth knowing are `deny: [memory.write, memory.forget]`, `deny: [skills.manage.*]`, `deny: [vault.put, vault.remove]` and `deny: [entities.write, entities.link, entities.forget]`. Name every capability that changes something: `deny: [memory.write]` alone leaves `memory.forget` granted. Two things it does *not* do. A vault read is still granted per item: `vault.*` covers `put` and `remove`, and a `vault.get.<id>` capability exists only for an item already in the store and only after a restart, so a write cannot hand itself a read. And a connection is never granted: an account still takes `lanes link connect` and a browser. **The repair runs on three commands, and not only for the profile you named.** A profile missing a surface serves none of it silently, because the capabilities are absent from `tools/list` rather than refused, so `start`, `connect` and `deploy` add the connection row and the `<provider>.*` rule to any profile missing both. `deploy` does this for every profile it is about to upload, which without `--profile` is the whole workspace, because a profile it sends is a profile the endpoint will serve. All three print what they added. A `deny` covering a surface stops the repair and keeps it off; deleting the two lines does not, because the next command puts them back. **Vault reads deserve stricter treatment than other reads.** Tools only, never resources, because resources are listable and cacheable, which is wrong for secrets, plus per-item policy through the capability name and aggressive audit redaction. A stored value is recorded as `<withheld>`, not as a type marker, because a secret's length is a real disclosure. **A vault write cannot hand itself a read.** Item capabilities are read when the runtime is built, so an item stored by `vault.put` is unreadable until the next start. Granting access to a new secret is a deliberate act by the operator between two runs. ADR-014 gave the registry a `replace` for skills; pointing it at the vault would turn this property into a race, and the tests say so. Source: https://lanes.sh/docs/link/security --- # Capabilities > Why each capability is a tool, a resource, or a prompt, where its list comes from, and what that decides about what can be checked. [Every provider page](/docs/link/providers) says what that connection can do. This page is the layer underneath: why a capability takes the shape it does, and where the list of them comes from. If you only want to know what an agent can reach, you do not need this page. ## Tool, resource, or prompt Three MCP primitives, decided per capability rather than per provider: - A **tool** is an action, or a query whose answer depends on its arguments. Searching mail is a tool, because the result is a function of the query, not of an address. - A **resource** is read-oriented context at a stable address. `memory://entry/{id}` is a resource; it is a function of its URI alone, which is why it has nowhere to put routing arguments. - A **prompt** is a procedure a person selects. Every [skill](/docs/link/skills) is a prompt, and that is what stops an agent choosing one on your behalf. Enumeration is always a query rather than a document, so listing mailboxes, labels, or calendars is a tool everywhere. ## What redaction is for Every invocation is recorded, allowed or refused. The question each provider answers is which arguments are identifiers and which are content, because an audit log that reproduced the content would be a second copy of the thing it was meant to make accountable. The rule across every provider is the same: **keep the shape, withhold the words.** What each one keeps is on its own page, and [the audit log](/docs/link/audit) has the reasoning that applies everywhere. ## Where a capability list comes from Three sources, and which one a provider uses decides what can be checked. **A vendored OpenAPI document.** Google, Reddit, Discord, and bunq. The committed copy is what stops a breaking change upstream becoming a provider that stops working, and it makes the surface reviewable in a diff. Reddit's is hand-authored, because Reddit publishes no OpenAPI document at all; it is still a document rather than code, so the operations become capabilities mechanically. **The vendored list is the security boundary, not a convenience.** `connect` writes one rule per provider, and policy has nothing between a whole provider and one exact name. Discord is the clearest case: twenty operations out of 242, with `bulk_delete_messages` and every moderation, role, invite, and guild-settings endpoint excluded, and a test that pins the list so a spec refresh cannot widen it quietly. **The vendor's own MCP server.** GitHub, Slack, Notion, Linear, and most of the catalogue. Capabilities are discovered at connect time, so the tool list is theirs. That has a cost worth stating rather than leaving to be discovered: nothing in the codebase can check the argument names against the vendor's. If GitHub renames an argument, the value is withheld and the log reads exactly as it does when redaction is working. `lanes link doctor` reporting capability drift is the signal that the list wants re-reading. **Authored in the codebase.** Rare, and only where a document cannot express the thing. `gmail.send_message` is the example: assembling a MIME message with attachments resolved by reference is not something an OpenAPI description can describe. ## Why scopes are asked for narrowly A scope on a consent screen that no tool can spend is a grant asked for and never noticed, so each provider asks for the narrowest set that makes its capabilities work, and a scope that reaches further than the rest is marked **broad** and needs an explicit yes at connect time. Which scopes a provider asks for, and which it refuses, is on that provider's page. [Scopes and permissions](/docs/link/scopes) is how you change either. ## Two places a credential reaches the model Both are recorded as NOT-GUARANTEED rows in the [security model](/docs/link/security) rather than glossed over, and both are accepted because the alternative is not the same capability made safe but the capability not existing: - **Discord's `create_webhook` and `list_channel_webhooks`** return the webhook's token in their response. A webhook token is standalone: anybody holding it posts to that channel. It is bounded to one channel, with no read access. - **A pasted token**, for Slack or GitHub or Discord, is the credential itself rather than a means of obtaining one, so rotating it is manual and what it can do cannot be read back. ## The `example` provider No external service, no credentials, no browser. It ships as the provider SDK reference and as the way to exercise connection isolation without any accounts, and it is a [built-in](/docs/link/built-ins) in miniature. Its redaction is the pattern in one line: `echo` records its `message`, because the message *is* the whole payload; `set_note` and `get_note` record the `key` and never the `value`. A provider handling real correspondence would record neither. `example.set_note` and `example.delete_note` sit in the `write` bundle, which is not granted by default, so a fresh profile can read and echo but not modify. That is the smallest demonstration that bundles do something. --- **Next:** the [security model](/docs/link/security) for the guarantee table, or [Connectivity coverage](/docs/link/connectivity-coverage) for what the two closed lists reach. Source: https://lanes.sh/docs/link/capabilities --- # Connectivity coverage > Which connectivity and credential types compose, which are closed, and what none of them covers A provider is two choices: how a service is reached, and how we prove who we are. Both are closed discriminated unions: `connector.kind` in [`connector.ts`](https://github.com/lanes-sh/link/blob/main/src/connectivity/manifest/connector.ts) and `auth.kind` in [`auth.ts`](https://github.com/lanes-sh/link/blob/main/src/connectivity/manifest/auth.ts), and everything else about a provider is data hung off the pair. `lanes link connect custom` exists to compose them, so this page is the honest account of what that composition reaches. It is written to be read in two directions. If you are declaring a provider, the matrix says whether your pair works. If you are deciding what to build next, the second half says what no pair covers and what each gap would cost. ## How to read this Four states, not two: | | | |---|---| | **works** | Expressible in a manifest today. **Proven by** names a built-in that exercises it; blank means legal and unexercised, so nothing refuses it, and the first operator to declare it is the test. | | **closed** | `defineProvider` refuses it, on purpose. The rule is in [Why a cell is closed](#why-a-cell-is-closed). | | **not built** | Nothing refuses it in principle. No code exists. No cell is in this state today: it is what the gaps in the second half are. | | **n/a** | Not a combination to build. | One thing the union's shape hides: **`auth.assertion` is not a `kind`.** RFC 7523, which is to sign a JWT with a key you hold and exchange it for a token, hangs off the OAuth block instead, and the reasoning is the rule this whole page is measured against: > Declared *on* the OAuth block rather than as a fourth `kind`, because it is an alternative > arrangement for the same provider rather than a different provider. So it is a column here, not a row. Which arrangement a connection actually uses is decided by the *shape of the stored credential*, not by config. ## The matrix | connector | none | oauth | bearer | api_key | header | basic | strategy | oauth + assertion | |---|---|---|---|---|---|---|---|---| | **mcp** | works | works: `notion`, `linear`, `slack`, `gmail_mcp`, `drive_mcp` | works: `github` | closed **R7** | closed **R7** | closed **R7** | closed **R7** | closed **R4** | | **http** | works | works: `gmail`, `drive`, `sheets`, `docs`, `calendar`, `tasks`, `contacts`, `reddit` | works | works | works: `discord` | works | works: `bunq` | works: the seven Google providers, via a service-account key | | **imap** | closed **R5** | closed **R5** | closed **R5** | closed **R5** | closed **R5** | works: `icloud_mail`, `gmail_imap` | closed **R5** | n/a | | **dav** | closed **R5** | closed **R5** | closed **R5** | closed **R5** | closed **R5** | works: `icloud_calendar`, `icloud_contacts` | closed **R5** | n/a | | **fs** | works: `icloud_drive` | closed **R6** | closed **R6** | closed **R6** | closed **R6** | closed **R6** | closed **R6** | n/a | | **local** | works: the owner layer, `example` | closed **R6** | closed **R6** | closed **R6** | closed **R6** | closed **R6** | closed **R6** | n/a | Three readings worth stating plainly. **`http` is the row where the open cells are.** All eight of them are open, and it is where every custom provider that is not an MCP server or a mailbox will land. Four are unexercised, which is a gap in *evidence* rather than in the schema, and the one `connect custom` starts closing, which is why the round-trip test over every legal pair matters more than it looks. **`strategy` is only open on `http`, and that follows from what a strategy is.** It signs or negotiates an HTTP request, so it needs a request to sign: `mcp` sends exactly one header and permits only three credential types (R7), `imap` and `dav` authenticate with a password (R5), and `fs` makes no request at all (R6). `connect custom` refuses the other four by name. **R5 is the only closure with an expiry date.** Its stated reason is that OAuth for mail and DAV is partner-gated with no published scopes. That is a fact about 2025, not about IMAP. When a vendor publishes scopes, R5 becomes wrong and two cells open. `local` is in the matrix for completeness and cannot be declared: it means the capability code is ours, compiled into this build. `connect custom` refuses it by name. ## Why a cell is closed Every rule is in [`defineProvider`](https://github.com/lanes-sh/link/blob/main/src/connectivity/manifest/provider.ts), and each exists because the alternative validates and then does not work. | | Rule | What it closes | |---|---|---| | **R2** | `oauth` + `registration: manual` needs an `app`, and needs either a `broker` or setup prompts | a manual client with nowhere to come from. "Otherwise there is no way to learn what to provide." | | **R3** | a `broker` needs `manual`, an `app`, and an `authorize_url`, plus a `token_url` on `mcp`, and never a `redirect_uri` | an exchange with nowhere to route. On `mcp` the SDK owns the flow and "has nowhere to route an exchange somebody else performs" (ADR-040). | | **R4** | `mcp` + `assertion` is refused | the assertion column for `mcp`. "The SDK owns an mcp provider's exchange and takes a client, not a signed assertion, so the choice would be offered, accepted, and then have nowhere to go." | | **R5** | `imap` and `dav` must declare `basic` | 10 cells. "Every mail and DAV host that matters issues an app password and expects it over Basic. OAuth for these exists, and Apple shipped one in Oct 2025, but is partner-gated with no published scopes, so declaring it would be a manifest that validates and then cannot authenticate." | | **R6** | `fs` and `local` must declare `none` | 10 cells. "Nothing to authenticate to. The permission is the operating system's, held against the process, and there is no credential to store or to leak." | | **R7** | `mcp` auth must be `none`, `oauth` or `bearer`, may not rename its header, and `connector.headers` may not set `Authorization` | 3 cells. "The transport sends exactly one header, `Authorization: Bearer <token>`, because that is what the MCP specification says a client sends… Such a manifest validates and then connects *unauthenticated*: no error, an empty tool list, and nothing to read that says why." | | **R8** | an assertion needs non-empty `scopes` and its own prompts | a token "permitted to do nothing" that only reports at the first call. | | **R9** | a token credential may not declare both `app` and `credential_ref` | one secret per account across a vendor versus one across every account. "Pick the one that is true." | | **R10** | a `shared` prompt must name a ref; a `connection` prompt must not | a ref that "would name a connection that does not exist yet". | | **R11** | `basic` needs exactly one `username` prompt and one `password` | `basic` stores `username:password` and cannot be assembled from anything else. | There is no rule refusing `strategy` itself, and there was briefly one here that should not have been. A strategy names code, and whether a name reaches any is the *registry's* question rather than the schema's. `strategyFor` looks at the manifest's own definition and then at every other registered provider's, and `refuseStrategy` is what says a name reaches nothing. That indirection is the point: it is what lets a declaration-only manifest in `providers.d/` borrow a registered strategy by name, which is the only way to point a connection at a vendor's sandbox, since a built-in manifest's `options` are not the operator's to edit ([ADR-046](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/046-an-auth-strategy-belongs-to-its-provider.md)). `connect custom` refuses R4–R7 in its own words before writing anything, naming the alternative rather than the rule. The rule fires regardless a moment later; the sentence is the part somebody acts on. ## What a capability *is* depends on the connector This decides what a custom provider can do, which is a different question from whether it connects. | connector | capabilities come from | |---|---| | `http` | **discovered**, from the OpenAPI document, through `mcp-from-openapi`, filtered by `operations` | | `mcp` | **discovered**, from the upstream server's `tools/list` | | `imap` | **fixed**, in `imap/capabilities.ts`, conditioned on whether SMTP was declared and whether the server supports `MOVE` | | `dav` | **fixed**, in `dav/capabilities.ts`, conditioned on `caldav` or `carddav` | | `fs` | **fixed**: "a folder is a folder" | | `local` | **authored**, as Zod schemas in our own code | So a manifest adds *capabilities* only on the two discovered rows. On `imap`, `dav` and `fs` it chooses a host and some limits and gets whatever the protocol's fixed set is. That is the right trade and it is deliberate. As `connector.ts` puts it, "the capability set belongs to the connector rather than to the manifest", because IMAP describes its extensions and never its operations. ## What no cell covers Each gap ends with a cost, in one of four sizes: **schema only** · **a folder, a schema member and a resolve case** (the unit `connectivity/auth/README.md` claims for itself) · **a change to a transport or to dispatch** · **a whole transport**. One thing to read the auth gaps against first. Now that the strategy seam is real, several of them have a *second* answer that costs nothing here: a folder under `providers/` and a line in that provider's index. `sigv4` is the clearest case: request signing over a keypair the operator holds is exactly the shape `providers/bunq/strategy/` already is. A strategy is the right home when the arrangement belongs to *one vendor*, and a `kind` is the right home when it is a standard several vendors implement the same way, because only then does a shared implementation have anything to share. Client credentials below is a standard; SigV4 is one vendor's. ### OAuth 2.0 client credentials The commonest machine-to-machine arrangement, a client id and secret with no browser and no person, and there is no path to it. The only grant types in the tree are `refresh_token`, `authorization_code`, and `urn:ietf:params:oauth:grant-type:jwt-bearer`. The absence is recorded in the code: "The other flows the credential-type list names, client credentials and SigV4, are sibling folders that do not exist yet." **Cost: a folder, a schema member and a resolve case, plus two CLI touch points the README does not count.** `connect` needs a `ChosenMethod` member and an arm beside the assertion and pasted-token routes, because there is no browser to open and no static prompt to fill. One shape decision is worth settling in advance. As a new `auth.kind`, `rotatableCredentialRefs` returns nothing for it, so a deployed revision caching an access token gets no write binding and starts refusing about an hour after reporting healthy. Hung off the OAuth block as `auth.client_credentials`, following the assertion precedent, refs and grants are unchanged. The second shape is the right one, and it can cache in process memory and persist nothing at all, which is what the assertion path already does. ### A REST API with no OpenAPI document `openapi` is required on an `http` connector and there is no path around it: the transport hands the string straight to the generator, and discovery is entirely that generator's output. **There is already a zero-code answer, and it should be documented rather than built:** write a five-operation OpenAPI document by hand and put it beside the manifest. A relative `openapi:` resolves against the manifest's own directory, and `upload.ts` carries a `.json` in `providers.d/` to a deployed bucket along with the manifest. For a service where you want six calls and not six hundred, hand-writing the six is less work than reading a vendor's spec anyway. **Cost if built: schema only, plus a synthesiser.** An `operations: [{ id, method, path, parameters }]` block and a function that emits an OpenAPI document from it, leaving the transport and the discovery path untouched. A second declaration format is the thing to avoid; a second *front end* onto the same one is fine. ### GraphQL No transport handles it, and there is no cell for it. Linear rides `mcp`, which is the answer for any vendor operating an MCP server over their own GraphQL API, and increasingly they do. An `http` connector can technically reach `POST /graphql` if a spec describes it with a free-form body, and that is worse than nothing: the query becomes a string the model composes against a schema nothing validated, and `operations.include`, the filter that keeps a tool list reasonable, has exactly one operation to choose from. **Cost: a whole transport**, including introspection-driven discovery as the analogue of reading a spec. Out of scope, and the reason is not the cost: the set of services reachable by GraphQL and not by MCP is small and shrinking. ### Pagination, cursors, rate limits, retries None of it is expressible. The `http` transport is one `fetch` and one response: no retry, no backoff, no `429` handling, no `Retry-After`, no cursor following. The rate limiting that exists is inbound: token buckets protecting the endpoint, not the vendor. So it is the model's problem, mediated by the spec: a `pageToken` parameter becomes a tool argument, the next cursor comes back in the response body, and the model passes it again. A `429` is returned as an error result carrying the vendor's status line, and whether anything retries is up to the agent. **Cost: a change to a transport**, and worth flagging as a *different kind* of cost from everything in the auth README, whose whole claim is that its gaps touch no transport, no provider and no dispatch path. This one touches a transport. It is also the likeliest first surprise for somebody pointing a custom provider at a paginated list endpoint. ### `refresh_token: optional` cannot be written in YAML A vendor that issues a long-lived token and no refresh token needs `refresh_token: optional`, or `connect` treats the successful response as a failure. Slack is exactly this case, and declares it. but Slack is a built-in, and a built-in calls `defineProvider` directly. A YAML manifest cannot. The entropy check that guards a manifest refuses any key matching `_token` that is not a `_ref`, on the raw document before the schema sees it, and `refresh_token` matches. The check is right about the general case and wrong about this key. **Cost: schema only, in the sense that no behaviour changes**, but the fix is in `secret-detection.ts`, which guards every config file in the system, so it wants care rather than volume. An exact-name exemption for `auth.refresh_token`, or a rule that a value inside a known enum is not a credential. ### `redact` on a provider nobody authored A manifest without a `redact` block gets `redactAllValues`, which reduces every argument to a type marker. So the audit log records that something happened and nothing about what. **This is the right default and the page should not hedge about it.** The alternative is a default that leaks, chosen on behalf of an operator who has not yet seen the capability list. And as `provider.ts` says, it "is the only safe default when we did not author the capability and cannot know what is sensitive". It is also not a custom-provider problem: `notion` and `linear` declare no `redact` either. The built-ins that have one have it because somebody sat down and wrote it, and `tasks/redact.ts` records how easy it is to get wrong: "a wrong key here fails silently, because the lookup misses, every value is withheld, and it reads exactly like working redaction." There is a real option here, recorded and not recommended. By the time `connect` finishes it holds every discovered capability name and input schema, so it could write a commented `redact:` skeleton back into the manifest, one key per capability with an empty array, giving the operator somewhere to opt keys in that they will otherwise never find. **Cost: no schema change and no dispatch change**, but it introduces a class of write nothing in this codebase does: rewriting a manifest. ### Webhooks, server-push, and the protocols that are not here Out of scope with a reason rather than a shrug. The endpoint is stateless by design, so there is no stream on which to send a notification and no durable subscription to hold, and server-push also needs an inbound public URL. The decision is already applied once: Google Calendar's `watch` operations are excluded from the vendored spec because "the `watch` operations push to a webhook this endpoint does not have". SFTP, S3 and gRPC are each **a whole transport**, and each would follow the `imap`/`dav`/`fs` shape rather than `http`'s: a fixed capability set in the transport, because none of them has a document describing its operations. SQL should be refused outright, and it is worth saying why here rather than discovering it later: the policy layer's unit is a capability name, and the honest capability for a SQL transport is "execute an arbitrary query": one name covering everything, so one grant covering everything. Default deny would still be on and would still mean nothing. ## Keeping this page honest The examples below are held to the same standard as [`creating-a-provider.md`](/docs/link/creating-a-provider): `src/profile/docs.test.ts` parses every fenced YAML block on this page that declares an `id` and a `connector`, through the same `parseManifest` the loader runs. A matrix is more dangerous than a how-to, because its examples are exactly the edge cells nobody has run. One of each open row, so every cell claimed above has something behind it: ```yaml id: docs_server name: Docs Server connector: kind: mcp endpoint: https://mcp.example.com/mcp auth: kind: none ``` ```yaml id: thing name: Thing connector: kind: http base_url: https://api.example.com/v1 openapi: https://api.example.com/openapi.json headers: User-Agent: thing:1.0 (by someone) auth: kind: api_key header: X-Api-Key setup: prompts: - key: api_key label: Thing API key secret: true scope: connection ``` ```yaml id: mailbox name: Mailbox connector: kind: imap host: imap.example.com smtp: host: smtp.example.com port: 465 starttls: false auth: kind: basic identity: kind: connector setup: prompts: - key: username label: Username scope: connection field: username - key: password label: App password secret: true scope: connection field: password ``` ```yaml id: notes name: Notes connector: kind: fs root: ~/Notes auth: kind: none identity: kind: connector ``` Every one of these is what `lanes link connect custom` writes for the corresponding pair, and the command's own tests derive all twelve legal pairs and assert each survives being read back by the loader, so this page and that command cannot disagree for long. Source: https://lanes.sh/docs/link/connectivity-coverage --- # Deploying to Cloud Run > Cloud Run in full: cold starts, scaling, IAM, the image The deployed workspace runs the same code as the local one. A workspace names an adapter set, and that is the only thing that changes: the encrypted credential file becomes Secret Manager and the local directory becomes a bucket. Everything above them, meaning connections, providers, policy, and limits, is declared once and applies to both. ```console $ lanes link start --profile personal --workspace cloud # local: a directory, an encrypted file $ lanes link deploy --profile personal --workspace cloud # deployed: one bucket, Secret Manager, Cloud Run ``` **Two standing dependencies, and that is the whole list.** No database: state is one object per key and the audit log is one object per event, both in the same bucket as memory, tasks, assets, skills, attachments, and the config itself (ADR-020, ADR-021, ADR-023). If you find yourself needing an application-layer change to make the second one work, that is a bug in the adapter boundary rather than a step in this guide. --- ## What you need - A Google Cloud billing account. - The [Google Cloud CLI](https://cloud.google.com/sdk/docs/install), authenticated: `gcloud auth login && gcloud auth application-default login`. That is the list, and note what is *not* on it: a project. There is no second vendor to sign up with, no database to provision, and no key pair to mint in a console. The `gcs` adapter authenticates as the service account the deploy creates, so the bucket needs no credential of its own. **The project, its billing link, the APIs, the Artifact Registry repository, the bucket, the runtime service account and its IAM bindings are all created by `lanes link deploy` on its first run.** Every one of them follows from what the workspace declares or from one answer at a prompt, so transcribing them into a console was work with no decision in it. `--dry-run` prints the whole sequence before any of it runs. A project of its own is the default because it is the cleanest boundary available: this one holds the bucket, the credential store with your live refresh tokens, and nothing else. Deploying into a project you already use for other things works, and you type its name at the prompt, but it means a `secretAccessor` binding in a project whose other workloads you now have to think about. ## The config Two files, one workspace. The connections are the workspace's; the profile selects from them. **You do not write this.** `lanes link deploy` asks for the handful of things it cannot derive: project, billing account, region, service name, bucket, and who may reach it. and writes the whole block, proposing a default for every one. It is shown here because it is worth being able to read, and because you may want to edit it later; it is not a transcription exercise. Every adapter below has exactly one workable answer on Cloud Run, and one of the wrong ones (`storage: filesystem`) *appears* to work. ```yaml # connections.yaml, in the bucket contract: 3 connections: - id: main provider: gmail account: you@example.com ``` ```yaml # profiles/personal.yaml, in the bucket contract: 3 instance: profile: personal port: 7337 auth: mode: bearer token_ref: profile/token # How a remote client, a Claude or ChatGPT connector, including on a phone, # gets a token. It signs its owner in at lanes.sh and comes back with one; the # bearer token above is for CI, which has no browser. See "Who can reach it". authorization: mode: self grants: - connection: gmail.main allow: ['gmail.*'] # Who may consume it. A deployed workspace bound to a Lanes workspace may name # anyone that workspace holds, which is how somebody else reaches this endpoint # without being given a credential. members: - { subject: lanes:3QBmAxJLLrYSMTVUIeCN1SKFbdD3, role: owner } ``` ## The deploy loop ```console $ lanes link deploy --profile personal --workspace cloud # everything, from nothing $ lanes link connect gmail --profile personal --workspace cloud # a browser consent per account $ lanes link outputs --profile personal --workspace cloud # the URL an agent needs ``` `connect` publishes the config to the bucket the revision reads and asks the revision to re-read it, so it takes effect without a second deploy. Deploy again when the *code* changes. ### More than one deployment `cloud` is a workspace name rather than a keyword. A second deployment is named on the deploy that creates it, and everything downstream takes the same flag: ```console $ lanes link deploy --profile personal --workspace staging # surveys, writes workspaces.staging, rolls a revision $ lanes link workspace list --profile personal # what this profile declares, and which is in play $ lanes link secrets push --profile personal --from cloud --to staging $ lanes link outputs --profile personal --workspace staging ``` The revision carries its own name: the rollout sets `LANES_LINK_TARGET=<workspace>` on the service, so a `staging` container opens `staging`'s adapters. Give each its own project and bucket unless you intend them to share a credential store; the survey proposes fresh names, so pressing return through it is the safe answer. **`deploy` always names its `--workspace`** (ADR-037). It used to infer one, the workspace declaring a `deploy` block, inventing `cloud` when there were none, and that inference was a defence against `instance.default_target`, which is `local` and by definition not deployed. With the fallback gone the defence has nothing to defend against, and what was left was three behaviours from one command line on the command that creates cloud resources and rolls a public URL. **What it does not name is a profile.** A deploy sends every profile declaring the workspace, in one revision, because that is the set the endpoint will open (ADR-009, ADR-041). `--profile` narrows it. A first deploy is the exception: a workspace nothing declares yet has no set to derive, so name the profile it belongs to. `deploy` is the only command that may name a workspace which does not exist yet, since creating it is what a first deploy is for. That is also why it does not read `LANES_LINK_TARGET`, where a typo would be surveyed and rolled out rather than refused. `lanes link deploy` runs `check`, asks for anything the config does not say yet and writes the answers into your profile, creates the project-level resources on a first run, gets the credential store to a state a revision can boot from, uploads the workspace, builds the image through Cloud Build, rolls a revision, and prints the URL. It is a wrapper, not a deployment engine: there is no state file, no lease, no drift reconciliation, and no rollback manifest, because Cloud Run revisions already are the rollback. **The order of those matters and used to be wrong.** Provisioning ran after the credential check and the workspace upload, which is fine on every deploy after the first and impossible on the first: the credential check asked Secret Manager for a token in a project where that API was not enabled yet, and the upload wrote into a bucket that did not exist. Both failed several steps before the step that would have created what they wanted. **`connect` comes second, not first.** It authorises against a real account and writes into the workspace's credential store, which on a first run does not exist until `deploy` has enabled Secret Manager and created the bucket. The second `deploy` is not a formality either: a revision decides which connections are usable during its boot reconcile, so one that came up before an account was authorised goes on refusing it until a new revision replaces it. `deploy` prints both the exact `connect` commands and this reminder when it finishes. `lanes link deploy --dry-run` prints every `gcloud` invocation without running any of them, and reads and writes no credential. Use it the first time, and any time you want to run a step yourself. ### What it asks **Every run, not just the first.** With no `cloud` workspace at all it asks for the whole thing: project, billing account, region, service name, bucket, who may reach it, and whether a remote client has to, and writes `workspaces.<workspace>`, plus `auth.authorization` when the last answer is yes. With a workspace already declared it asks only about the deployment: project, region, service, access. Every prompt defaults to what the config already says, so **pressing return through the survey changes nothing and re-generates nothing.** By the second run the random project and bucket names are stored values, not fresh draws. What it buys is that the four settings deciding where a revision lands are in front of you each time rather than in a file you have to remember to open, and changing one costs a line instead of an edit. The adapters are not re-asked once declared. The bucket holds the config, the state and the log, so renaming it does not move a deployment, it abandons one: a deliberate edit rather than a prompt to press return through. `--access` overrides for a single run without writing anything. Slow lookups say so. Reading your `gcloud` configuration and checking whether a project exists both shell out and can take seconds, and a prompt that is *about* to appear looks exactly like one waiting for input. Press return into that silence and the terminal buffers the keystroke for the question you never saw. Each of those waits now prints what it is doing and takes the line back. **A run with nobody at the keyboard skips the survey** and uses what the config holds, so a scripted deploy needs no flag. `--non-interactive` says the same thing explicitly, for a terminal attached to a job nobody is watching; it assumes the "create these now?" confirm too. Neither can rescue a workspace whose answers are missing. That still refuses, at the prompt it could not ask. **The names it proposes.** A project id and a bucket name are both unique across every Google Cloud customer, so five random letters are drawn once and both take the same name: `lanes-link-<random>`. One string, two namespaces, and finding either from the other needs nothing written down. They are written into your profile, so the second deploy reads them back; a fresh suffix per run would name a fresh empty project beside the one holding everything. The service name carries the profile instead: `lanes-link-<profile>-mcp`. That is the name that has to differ when one project serves two profiles, and the one you read in the Cloud Run console months later. The default project used to be whatever `gcloud config` was pointed at, a value that is always set, rarely the right one, and wrong in the expensive direction: accepting it puts a credential store holding live refresh tokens into whichever project you last worked on. **The billing account** is asked only when the project does not exist yet, since that is the only time it changes anything, and it is refused rather than defaulted when the login has no open account, because a project without billing enables no API, and every step after it then fails describing the API rather than the billing. ### Getting the credentials in A deployed instance never mints its own token, and writes exactly one thing: the vault document, if you use the vault. Everything else flows one way, from your CLI. `deploy` mints the endpoint bearer token into the workspace's store if there is none, and asks for nothing else. A value already in the store is left alone. If you would rather do it up front, or copy a setup you already built locally: ```console $ lanes link token rotate --profile personal --workspace cloud # mints the profile bearer token $ lanes link secrets push --profile personal --from local --to cloud # or copy a setup you built locally ``` `secrets push` copies; it never deletes from the source, and it skips a reference the destination already holds unless you pass `--overwrite`. That default matters: a token rotated against the cloud workspace is newer than your local copy, and overwriting it silently would break the deployed instance with no error anywhere. `lanes link secrets set` reads the value from **stdin**, not from an argument. An argument is in your shell history, in `ps` output while the command runs, and in any transcript of the session. ### What the service account needs Four bindings, each narrower than it looks: | Grant | Scope | Why not wider | |---|---|---| | `roles/secretmanager.secretAccessor` | **one binding per secret it reads** | Read at boot and while serving: OAuth refresh tokens, the endpoint's own bearer token, the vault key. | | `roles/secretmanager.secretVersionAdder` | **one binding per secret it rotates** | The vault document, and each connection's OAuth token. Add a version, never create. See below. | | `roles/storage.objectAdmin` | **conditioned** on `data/`, less each profile's `providers.d/` | What the endpoint owns and writes: state, the log, attachments, memory, and skills (writable under policy, ADR-014). Manifests are carved back out, because they are config, and ADR-007 says a revision never rewrites its own. | | `roles/storage.objectViewer` | the bucket itself, plus `profiles/`, `lanes-link.yaml`, and each `providers.d/` | Reading its own config. Deliberately *not* admin, as below. The bucket is named as well because listing is granted there and nowhere else. | `deploy` creates the account and all of them on a first run; `--dry-run` shows them, and `--service-account` names a different one. **And it takes away the ones it replaced.** `gcloud ... add-iam-policy-binding` *adds*: a binding is keyed on role, member and condition together, so changing a condition's expression writes a second binding beside the first, and IAM evaluates the set as a permissive union, so the widest expression wins. Three deploys in a row narrowed `reads-its-config` while the revision went on holding `objectViewer` on every object in the bucket, under a title claiming the opposite, because the two attempts before them had been refused by CEL and every step here tolerates failure. So each deploy reads the policy it is about to change and removes what it superseded: a binding under one of these condition titles whose expression is no longer the one being applied, an unconditioned binding on a role that is only ever granted conditionally, and the project-wide `secretAccessor` that per-secret reads replaced. Additions run first and removals after, always, the two are one edit to a live policy, and the other order opens a window in which the revision currently serving holds no grant at all. Nothing is recorded between deploys to make that work. There is no state file, no lease and no drift reconciliation ([`init.md`](https://github.com/lanes-sh/link/blob/main/docs/detailed/init.md) rules all three out, and a record would only ever agree with itself); the policy is read, because IAM is the thing that actually decides. A policy that cannot be read, whether that is no `gcloud`, a bucket that does not exist yet, or a login without the permission, plans no removals at all rather than guessing. **Why the write grant is conditioned.** ADR-007 says a deployed instance never mutates its own configuration. That used to be enforced by the config being baked into a read-only image, which stopped being true when the workspace moved into the bucket (ADR-023). The condition is where that guarantee went: the revision may write what it owns and may only read what declares what it is. A blanket `objectAdmin` would silently undo it, which is why `driver.test.ts` asserts the shape. **Why the read grant also names the bucket.** `storage.objects.list` is checked against the *bucket*, never against an object. A prefixed listing is one call to the bucket carrying a filter, not a walk of matching resources, so no condition written in terms of `objects/…` can grant it, and Google says as much: IAM conditions cannot restrict object listing by prefix. The condition therefore admits `projects/_/buckets/<bucket>` as well as the three config paths. The only permission that can follow from it is `storage.objects.list`. Everything else in `objectViewer` is evaluated against an object, where the prefixes still decide, or is project-level and out of a bucket binding's reach. So the concession is the *names* of what is in the bucket; reading any of it stays where ADR-007 puts it. This was invisible for as long as the read binding sat at `expression=true`, which matches the bucket as readily as an object. The first deploy that actually removed that binding rolled a revision that could not list its own workspace, and `grants.test.ts` had not caught it because it evaluates the conditions against object keys, and a listing has no object in it. It does now. **Why the Secret Manager write grant is per secret.** The line is not read versus write, since the revision plainly writes. It is *rotating what exists* versus *bringing something into existence*. Two things it does are writes: - **Refreshing an OAuth token.** Access tokens last about an hour and the refreshed one is persisted, so an ordinary "check my mail" rewrites `gmail/<connection>` a few times a day. This is not a background job you can grant separately; it happens inside the request. - **`vault put`**, which is a write an agent may legitimately make under policy (ADR-022). So `deploy` binds `roles/secretmanager.secretVersionAdder` on each of those secrets by name, and creates each container itself so the revision never needs `secretmanager.secrets.create`, and a project-level permission that would let it mint credential references of its own, and destroy versions. A binding on one secret is already scoped to it, so none of these needs a condition to be narrow. See ADR-026. The list is scoped exactly as the upload is: a deploy naming no `--profile` sends every profile up, so it binds every profile's connections. A connection authorised *after* a deploy has no binding, and no config in the bucket either, so the revision cannot reach it at all until the `lanes link deploy` that `connect` already tells you to run. ### Credential references become secret ids Secret Manager ids allow `[A-Za-z0-9_-]`, and a credential reference contains `/`. The adapter encodes the separator as `__`, so `gmail/main` is stored as `gmail__main` and `vault/document` as `vault__document`. That encoding lives entirely inside the adapter, so every command still speaks in references, but it is what you will see in the console, and it is why a reference whose own segments contain `__` is refused rather than silently sharing a secret with another one. Secrets in the project that do not decode to a valid reference are ignored, so sharing a project with other workloads is fine. --- ## Storage is not optional up here `adapter: filesystem` on Cloud Run **appears to work**. Every write succeeds, every read within the life of an instance succeeds, and the bytes are gone when the instance recycles, which it does on idle, on deploy, and whenever the platform feels like it. Nothing errors, because from the container's point of view nothing is wrong. That was worth stating when the bucket held only memory and skills. It is worth more now: the bucket also holds the config the endpoint reads, its connection state, its audit log, every task, and every file kept as an asset. A deployment on the filesystem adapter is one that forgets what it did, and for assets that is the only copy, since the point of keeping one is that the endpoint can reach it from anywhere. That mix is also why the deploy creates the bucket with **Autoclass**, terminal class `ARCHIVE`. The same bucket holds the config read on every boot and an attachment nobody opens twice, so no single storage class is right for it and a lifecycle rule would be a guess written by hand. Autoclass moves each object on its own access pattern: untouched for thirty days it cools to Nearline, and it keeps sinking to Archive from there. Inside such a bucket there are no retrieval fees and no early-deletion fees, so a read pulls the object back to Standard at no charge. That is what makes the colder floor safe rather than a bet on never needing the file again. Objects under 128 KiB never leave Standard at all, so the config, the state and the log rows are untouched by this; the saving is on assets and attachments. It applies to a bucket the deploy creates. A bucket from an earlier deploy is left exactly as it is (the create step finds it present and moves on) so turning Autoclass on for an existing one is a change you make yourself, in the console or with `gcloud storage buckets update`. ### What protects the bucket, on every deploy rather than only the first Three things, and they are applied by a `buckets update` step precisely because of the paragraph above: a flag on the create reaches a bucket made after the change and no other, and every deployment that already exists is the one holding an audit log worth keeping. - **Public access prevention, enforced.** Nothing here is served to a browser, so the useful setting is the one that makes granting anonymous read impossible rather than merely absent. Uniform bucket-level access already removed per-object ACLs; this removes the bucket-level route. - **Soft delete, thirty days.** The revision holds `objectAdmin` on everything under `data/`, and `objectAdmin` contains `storage.objects.delete`. That grant is right, because the endpoint writes state, memory, tasks, assets and the log, and rewriting an object is deleting the old one, but it means the process most exposed to the internet is also the one that can erase the record of what it did. Thirty rather than the platform's seven, because the gap this closes is noticing late. - **Object versioning**, with a lifecycle rule that bounds it (`src/deployments/gcp/lifecycle.json`: noncurrent versions go at thirty days or ten newer copies, whichever comes first). This covers what soft delete does not: an object *overwritten* in place, where the previous content is the thing worth keeping, and state is the one thing here that is rewritten rather than appended. None of this makes a deletion *detectable*. `audit.tamper-evident` in [`security.md`](/docs/link/security) is explicit that deleting a run whole is not, and that has not changed. It makes it undoable. ### The vault key, which the deploy now mints The vault document is sealed before it reaches Secret Manager, under `LANES_LINK_VAULT_KEY`, a different key from `LANES_LINK_CREDENTIAL_KEY`, deliberately, because one master secret reused across purposes turns any single compromise into a total one. `lanes link deploy` mints it, the same way and for the same reason it mints the endpoint token: it is a random string the process generates correctly and nobody can usefully choose. It is stored at the reference `vault/key` in the workspace's own credential store, the secret `vault__key`, and mounted on the revision as `--set-secrets LANES_LINK_VAULT_KEY=vault__key:latest`, so the value never appears in argv, in a revision's description, or in anything `describe` returns. This used to be three manual commands, and their only failure mode was forgetting them: the revision booted healthy and every `vault.*` call failed at the first read. Beside the document rather than somewhere else, which reads wrong and is not: ADR-022 put the ciphertext in Secret Manager *because* the key already came from there. What separates them is that the key is mounted as an environment variable and the document is not, so an attacker holding the document alone holds ciphertext. It is never regenerated. A second key does not fail loudly; it decrypts nothing, and what it cannot open is every password the owner put there. `lanes link vault key generate` still exists, for a local run or a key you would rather carry yourself. ## Cold starts With minimum instances at zero, the first agent call after an idle period pays for: 1. **Container start**, pulling and starting the image, then Bun booting. Bun runs TypeScript directly, so there is no bundle to load, and the image is small. 2. **Config read**, a couple of object reads from the bucket. There is no database to connect to and no schema to migrate, so this step is two HTTPS requests rather than a TLS handshake plus a lock. 3. **Reconcile**, one plan per profile against the config just read. Expect a few seconds. An agent call that normally takes a second takes several after idle, once, and then not again until the instance is reaped. Your options are exactly two, and both are legitimate: - **Accept it.** For a personal gateway this is the right default. `--min-instances=0` costs nothing while idle. - **Set `--min-instances=1`.** The first call is fast every time, and you pay for an always-warm instance. ```console $ gcloud run services update lanes-link-personal-mcp \ --min-instances=1 --region europe-west1 --project my-project ``` Leave CPU allocation at "CPU only during requests". The server is stateless and does no background work between requests, so allocating CPU always would pay for idle time and buy nothing. --- ## Who can reach it, and what scaling changes There are two doors, and they are not alternatives. They are layers, and only the inner one can admit an agent. **The platform door** is `deploy.access`. `iam` deploys with `--no-allow-unauthenticated`, so Cloud Run checks the caller before the request ever reaches this code. What it checks is a *Google-signed identity token* for this service, held by a principal with `roles/run.invoker`. No agent harness can mint one, not Claude, not ChatGPT, not a `claude mcp add` registration, so `iam` is the right choice for a service reached by other cloud workloads and the wrong one for a service reached by an MCP client, which will see nothing but 403s. **The application door** is `src/auth`, which does not know or care which workspace it is running on. It accepts two kinds of credential: - The **profile bearer token**, resolved from the credential store, the same mechanism as locally. This is what `claude mcp add --header` and every local registration carry. - A token obtained through **`auth.authorization`**, for a remote client that has nowhere to paste a fixed string. `access: iam` is the default, because a workspace that says nothing about who may reach it should get the closed answer. A workspace you intend to reach from an agent declares `access: public`. `--access iam|public` overrides the declared value for one run; `--iam` is still accepted and means `--access iam`. There is no way to serve without authentication. `--no-auth` existed, printed that it had disabled authentication, and did not: the flag reached a bind-address guard and never reached an authenticator, so every request was still refused. It has been removed rather than repaired: the loopback endpoint it would have opened has no Origin check standing behind it, so any page the owner visited could have read their accounts. `/health` answers `{"status": "ok"}` without a token, so the platform's probe and `deploy` can wait on it. It names the profiles it serves only to a caller holding one, because that list is an inventory of what this instance holds, and a deployed URL is readable by anyone. ### Connecting a phone `mode: self` means this endpoint issues the tokens, and there is nothing to set up: no OAuth client, no console, no redirect URI. Deploy, then add a custom connector by URL in Claude or ChatGPT. The client registers itself, a browser opens on this endpoint's approval page, and you paste the endpoint token once, the string `lanes link outputs --show --workspace cloud` prints. That is the whole flow, and it works the same on a laptop and on a phone. Name the workspace. Credentials are per-workspace, and a bare `lanes link outputs --show` resolves to `instance.default_target`, which is `local` on a scaffolded profile, whose token this endpoint has never seen and will refuse. Worse, when that store is empty the command mints a fresh local token rather than reporting that it has none, so what you paste looks like an answer and fails as a wrong password. The approval page prints the workspace it is actually running as, so the command it shows you is the one to run. What makes it work is a handshake worth knowing about when it does not: `/mcp` answers `401` with a `WWW-Authenticate` header pointing at `/.well-known/oauth-protected-resource`, which names this origin as the authorization server, whose own document lives at `/.well-known/oauth-authorization-server`. All three are readable without a token, deliberately, they are how a client learns it needs one. If a connector reports the server as unreachable, curl those three in order; the first one that does not answer is the problem. ```console $ curl -i -X POST https://…run.app/mcp | head -3 # 401, with resource_metadata $ curl -s https://…run.app/.well-known/oauth-protected-resource | jq $ curl -s https://…run.app/.well-known/oauth-authorization-server | jq ``` ### Calling it from a browser Nothing to configure. A deployment answers a cross-origin request from any page, because there is nothing for an allowlist to defend: the endpoint is already reachable by anyone, the credential is an `Authorization` header a page must already hold rather than a cookie a browser attaches on its own, and `Access-Control-Allow-Credentials` is never sent. To narrow it anyway, as an enterprise deployment might, name the origins: ```yaml auth: mode: bearer token_ref: profile/token allowed_origins: - https://app.example ``` An origin exactly: scheme, host, and port, with no trailing slash and no path. A browser sends `Origin: https://app.example`, and a configured `https://app.example/` compares unequal and would refuse the origin you believed you had allowed, so the config refuses it up front rather than at request time. The discovery documents are never narrowed by this; a client that cannot read them cannot find out that it needs a token. Two things it does not do. It grants no capability: what a caller may do once it holds a credential is decided by `policy`, per call, exactly as for every other client. And it does nothing at all for `lanes link start`. A loopback endpoint refuses every cross-origin request and must keep doing so, because a page you happen to be visiting can otherwise reach `127.0.0.1`, including the consent form that asks you for your token. The field is read and discarded there. See [ADR-039](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/039-cross-origin-access-is-a-deployment-only-grant.md). ### Using an identity provider you already run `mode: oidc` points the same handshake at somebody else's authorization server and reduces this endpoint to verifying what comes back, against the audience, the expiry, and an allowlist of subjects. ```yaml auth: authorization: mode: oidc issuer: https://accounts.google.com client_id_ref: oidc/client_id # Only when the issuer publishes no `introspection_endpoint` of its own. introspection_endpoint: https://oauth2.googleapis.com/tokeninfo allowed_subjects: [you@example.com] ``` The cost is setup, and it is worth being clear about before you choose it. The issuer needs an OAuth client registered for this endpoint, with `https://claude.ai/api/mcp/auth_callback` among its redirect URIs, and the client id stored at `client_id_ref`. Google in particular supports neither dynamic client registration nor client-ID metadata documents, so its client id and secret have to be pasted into the connector's advanced settings by hand, and ChatGPT, which needs dynamic registration, likely cannot use that combination at all. `mode: self` is the default for exactly these reasons. See [ADR-018](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/018-the-gate-is-in-the-application.md). The `allowed_subjects` list is not optional and may not be empty. An issuer will vouch for every account it has; which of them is *you* is not something it knows. **Rate limits are per instance.** `limits.requests_per_minute` is enforced by an in-memory counter, so a service running N instances enforces N times the configured limit in aggregate. A shared counter store would be needed for a global limit, and that is not in scope. What bounds the aggregate instead is `max_instances`, below. This used to say "cap `--max-instances` accordingly" and nothing in `deploy` ever sent the flag, so the aggregate had no ceiling at all. ### The ceilings a revision runs under Five settings the rollout sends on every deploy, defaults included, for the reason `min_instances` is also always sent: config decides, and a flag passed only when it differs from a default lets a value be raised and never lowered. Absent, each fell to the platform's own default, a hundred instances, eighty concurrent requests each, and 512 MiB to stage a 64 MiB upload in. ```yaml workspaces: cloud: deploy: platform: cloudrun # ... min_instances: 0 max_instances: 4 # instances concurrency: 40 # requests per instance timeout_seconds: 300 memory: 1Gi cpu: "1" ``` You do not write these either. The survey carries them through, and pressing return changes nothing. They are here because they are worth being able to find. `max_instances` is the one that matters on a `public` workspace. That is a routable address anyone can send a request to, and every instance that starts reads the credential store and lists the bucket, so scaling out multiplies cost *and* traffic against the two things this endpoint most wants kept quiet. Four is a single-user endpoint's ceiling; a fifth concurrent instance is an agent in a loop. Raise it if you are genuinely serving that much. `memory` is a gigabyte because 512 MiB is not enough for what the endpoint already accepts: an attachment is capped at 64 MiB and staging one costs roughly twice that at peak, so the platform default is one upload away from an out-of-memory kill, which is a 503 for every other request that instance was serving. ### What answers before the token does Four things answer without a credential, and on a `public` workspace that means to anybody: `/health`, `/register`, `/authorize`, `/token`. Each costs a read of the credential store or an object written to the bucket, so each is metered: two buckets, one per caller and one for the endpoint, because the per-caller key is a forwarded address a stranger can rewrite. A `/health` carrying **no** credential is free and stays free: it reads nothing, and it is what the platform's probe and `lanes link outputs` send. None of this applies to `lanes link start`. A loopback endpoint's credential store is a local file belonging to whoever is already at the machine, and the cross-origin refusal covers the one caller who is not. See [ADR-054](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/054-the-surface-in-front-of-the-gate.md). --- ## The image `src/deployments/gcp/Dockerfile`, built from the repository root through `src/deployments/gcp/cloudbuild.yaml`. Three things about it are worth knowing. **It is pinned by digest.** `FROM oven/bun:<version>@sha256:<digest>`, with the tag kept beside it so the next bump is legible. A tag is a pointer its publisher can move, so it is not a promise about bytes: a rebuild months from now can pull a base image nobody reviewed, into a container holding live refresh tokens. To bump it, change the tag and resolve it with `docker buildx imagetools inspect oven/bun:<tag> --format '{{.Manifest.Digest}}'`. **The config is not in it.** The image carries no `lanes-link.yaml` and no `profiles/`; `deploy` uploads them to the bucket and passes `LANES_LINK_HOME=gs://<bucket>` at rollout, so one image serves any workspace (ADR-023). It used to be baked in, and the image being read-only was what enforced "a deployed instance never mutates its own configuration". That guarantee now lives in the conditioned `objectAdmin` binding, which is why the condition is worth keeping narrow. What that costs is rollback. A revision no longer fully describes what it serves, so rolling back to an earlier revision does not roll back a config change made since; the bucket holds one current copy. The upload is an allowlist: `lanes-link.yaml`, `profiles/<profile>.yaml`, and the two authored directories inside the profile, `data/<profile>/skills.d/` and `data/<profile>/providers.d/`, so the rest of `data/` cannot travel by accident, for the same reason `.dockerignore` excludes it. **Everything else under `data/` is excluded, and that exclusion is load bearing.** The local encrypted credential store and its key live there. A credential baked into an image is pushed to a registry, cached on every builder that touched it, and readable by anyone who can pull the tag. The deployed workspace reads credentials from Secret Manager and wants nothing from that directory. It takes **two** files, because there are two things to stay out of. The root `.dockerignore` keeps them out of the image. `.gcloudignore` keeps them out of the tarball `gcloud builds submit` uploads to a Cloud Build staging bucket, which is packed and sent before any Dockerfile is read, so `.dockerignore` has nothing to say about it. Without the second file gcloud derives its exclusions from `.gitignore` when the context happens to be a git checkout and from nothing at all when it is not, and `deploy` sends the *installed package*, which for the documented install method is a directory under `~/.bun` with no `.git` in it. The safe behaviour was being inherited from a coincidence. Keep the first block of the two files in step; `dockerfile.test.ts` checks that you did. The entrypoint is `src/server/container.ts`, not `lanes link start`. It logs plain lines to stdout for Cloud Logging, handles SIGTERM, listens on `$PORT`, and, importantly, **refuses to start when the profile token is missing** rather than minting one. A token invented inside a container that scales to zero is a token nobody can read back, and the endpoint would come up healthy while rejecting every agent. To run the image locally against the cloud workspace's adapters: ```console $ docker build -f src/deployments/gcp/Dockerfile -t lanes-link . $ docker run --rm -p 8080:8080 \ -e LANES_LINK_TARGET=cloud \ -e GOOGLE_APPLICATION_CREDENTIALS=/adc.json \ -v ~/.config/gcloud/application_default_credentials.json:/adc.json:ro \ lanes-link ``` --- ## Troubleshooting **`No profile token at "profile/token"`.** The container refuses to invent one. Run `lanes link token rotate --workspace cloud`. A running revision re-reads within five seconds, so neither a redeploy nor a fresh instance is needed; it used to be, because the value was cached for the life of the process. **`PERMISSION_DENIED: Permission "secretmanager.versions.access" denied`.** The revision's service account is missing `roles/secretmanager.secretAccessor`. The adapter passes Google's message through verbatim, and it names the permission. **`PERMISSION_DENIED: Permission "secretmanager.versions.add" denied`, on a read.** Reading mail refreshes an OAuth token and persists it, so the credential's secret needs a `secretVersionAdder` binding and this one has none. Almost always a connection authorised since the last deploy. `lanes link deploy` binds it; the message says so and names the ref. **`PERMISSION_DENIED: Permission "secretmanager.secrets.create" denied`.** Do **not** reach for `roles/secretmanager.admin` here. It reads as "the secret does not exist yet" and usually does not mean that: Google checks IAM before existence, so this is also what a create against a secret that is right there answers. Nothing in a running revision should be creating a secret at all, so a 403 here on the serve path means something asked for a permission it does not need, which was a real bug, fixed in ADR-026. On the CLI side it means your own account cannot create secrets in that project. **`PERMISSION_DENIED: The caller does not have permission` during a step, on a project you own.** enabling an API returns before the API is usable, and inside that gap Google answers calls to it with a permission error rather than a "still starting" one. `deploy` enables seven APIs and then uses all of them, so it is the most likely thing here to land in that window. It now waits: a step that fails this way is retried with a backoff, against a budget of about two and a half minutes shared across the whole run, and says so on each retry. If it still fails afterwards the message is Google's and the problem is real. **`GCS refused to write "…" (403)`.** The revision's service account is not granted `roles/storage.objectAdmin` on the bucket, or the deploy's IAM step was skipped. The message names the role. Note the grant is conditioned: the revision may write under `data/` and may only *read* the config paths, so a 403 on `profiles/…`, or on a `providers.d/…` key, which sits inside `data/` and is excluded from the write grant by name, is the guarantee working rather than a misconfiguration. **The endpoint answers 401 for a token you just printed.** `claude mcp add` stores the substituted value, not the command, so a rotated token needs re-registration. `lanes link outputs` prints the command. **`lanes link outputs` shows a local URL for a cloud workspace.** It asks Cloud Run for the service URL and falls back to the configured host and port when `gcloud` is absent or the service is not deployed yet. ## A brokered Google connection on Cloud Run Nothing extra to bind. A connection authorised against the OAuth client Lanes operates rewrites exactly one secret while serving, its own token blob, which is already in the rotation grant, and it needs no client id or secret anywhere in the workspace's store. The one requirement is **outbound HTTPS to the broker host**, which Cloud Run has by default. It only becomes a question if you have set VPC egress to route all traffic: the revision refreshes through `api.lanes.sh`, so that host has to be reachable or every Google call fails an hour after the revision reports healthy. A profile that registered its own client is the other way round: its `oauth_apps` refs are bound **readable** by `deploy` so the refresh path can sign with them, and never writable. [ADR-026](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/026-a-revision-rotates-its-own-credentials.md)'s line is that a revision rotates what is its own and never rewrites the operator's client. Source: https://lanes.sh/docs/link/deployment-cloudrun --- # Registering your own Google client > The console walkthrough, service account keys, and the Workspace MCP servers Most people never need this page. [Connecting Google](/docs/link/google) authorises against a client Lanes operates, and that is the whole setup. This is the longer road, for when it is not enough. ## Registering a client of your own You would want to, and the rest of this page is how, if: - your organisation does not permit third-party OAuth clients; - all your accounts are on one Workspace domain and you want an **Internal** app, which never expires a refresh token and shows no warning screen; - you would rather the authorization code and refresh token never passed through the Lanes API (the [security model](/docs/link/security) states exactly what that gives up); - or the hosted client is at capacity. ```console $ lanes link connect gmail --profile personal --workspace local --own-client ``` It asks for a client id and secret, stores them, and writes an `oauth_apps` entry to your profile. That entry is the switch: once it is there, every Google connection on that profile uses your client and you never need the flag again. Going back to the hosted one takes two steps, not one. Deleting the entry leaves the client id and secret in your credential store, and they still count. That is deliberate, so a profile whose config lost the block is not moved onto a different client and left holding refresh tokens the new one refuses. Remove the stored pair as well, or remove the profile ([Configuration](/docs/link/configuration)). Either way, existing connections keep refreshing against whichever client issued them, so moving one across means running `connect` for it again. Everything below is that path. --- ## Two providers per product, and the default is the one that works This is a different question from which credential you use, below: it is which *API* you talk to. `gmail` and `gmail_mcp` are separate providers with separate tool lists and separate policy rules, and you connect one or the other by name. | | `gmail` / `drive` | `gmail_mcp` / `drive_mcp` | |---|---|---| | Talks to | the Gmail and Drive **REST APIs** | Google's **MCP servers** | | Who can use it | **anyone with a Google account** | Workspace Developer Preview members only | | Tools | generated from Google's OpenAPI description | curated by Google | | Setup | one OAuth client | one OAuth client, plus preview enrolment | **Use `gmail` and `drive` unless you have a reason not to.** They are the plain names because they are the ones that work. ### Why the MCP path is gated Google's MCP servers are in Developer Preview, and without enrolment they fail in the worst possible way, silently and late: ``` $ lanes link connect gmail_mcp --profile personal --workspace local ok authorised ← consent succeeds 13 capabilities discovered ← tools/list succeeds $ # …and then every single tool call: The caller does not have permission ``` Consent works. Discovery works. Only the calls fail, with a message that mentions neither preview nor enrolment. The identical token calling the REST API directly returns your labels perfectly, so it looks like a scope problem, and it is not. Enrol at [https://developers.google.com/workspace/preview](https://developers.google.com/workspace/preview). **It requires a Google Workspace account; a personal `@gmail.com` cannot enrol.** If all your accounts are personal Gmail, the MCP providers are simply unavailable to you. Use `gmail` and `drive`, which have no gate. --- ## Choose the right path first `lanes link connect <provider>` asks which route you want, and prints what each one reaches before you pick. There are up to three: | Route | Console work | Re-authorised weekly? | Reaches | |---|---|---|---| | The hosted client (default) | none | **while its verification is pending, yes** | the whole account | | Your own client, **Internal**, which needs a Workspace org | ~10 minutes, once per profile | **never** | the whole account | | Your own client, **External** | ~20 minutes, once per profile | **no, if you publish it**, see below | the whole account | | A service account key | ~10 minutes, once per profile | **never** | see [Service account](#connecting-with-a-service-account-key) | There is a fourth that is not on this list because it is not a way of connecting `gmail`. It is a different provider. `gmail_imap` reaches a personal mailbox over IMAP with an app password, which also never expires. See [Gmail over IMAP](/docs/link/google#gmail-over-imap). **The seven-day expiry is a property of publishing status, not of verification.** These are two different settings and confusing them is what sends people into the verification centre for a problem a checkbox solves. A client whose publishing status is **Testing** has every refresh token it issues expired after exactly seven days. A client set to **In production** does not, whether the review is pending, review never started, it makes no difference. That is why either own-client row beats the first one today: the hosted client is under review, and a client under review has whatever status it has. **An Internal app has no publishing status to have**, which is why its row says *never* rather than *not if you publish it*. There is no toggle to get wrong and nothing to remember to switch. If the project sits in a Google Workspace organisation and every account you connect is on that domain, this is both the shortest console detour on the list and the only own-client route with nothing to maintain: no test users, no verification, no scope registration. Its one prerequisite is real though: "Internal" means "inside my Workspace organisation", not "private to me", and Google does not offer it on a project with no organisation behind it. **Picking wrong is not a decision you are stuck with.** An account authenticates one way at a time, and `connect` is how it changes: run it again, pick another route, and the new credential replaces the old one on the same connection. Nothing has to be disconnected or removed first, and you do not end up with the account listed twice. The prompt says as much each time it asks, which is also the warning worth reading if you are re-running `connect` only to refresh a token: the last route you pick is the one that account uses from then on. One thing this does not do: it does not withdraw the access you had. Moving a connection off the browser leaves Google still holding the consent you granted, which you remove yourself at [myaccount.google.com/permissions](https://myaccount.google.com/permissions). ### If you register your own **Choose Internal if the project sits in a Google Workspace organisation and every account you will connect is on that domain.** This is the short path and it is much shorter: an Internal app has no publishing status at all, so there is no seven-day expiry, no verification question, no unverified-app warning screen, no test-user list to maintain, and no scopes to register on the Data Access page. Set the user type and go straight to creating the client. **Choose External for a personal `@gmail.com`, or a mix of personal and Workspace accounts.** "Internal" is Google's word for "inside my Workspace organisation", not "private to me". The option is not offered at all on a project with no organisation behind it, and where it *is* offered it admits only your own domain. Everything from here to the end of this section is the External path. **Then publish it.** Publishing an unverified app is allowed and is not the same as being verified. What it costs: - everyone you connect sees a **"Google hasn't verified this app"** screen and has to click through **Advanced → Go to \<app name\> (unsafe)**; - the project gains a cap of **100 new users** granted these scopes, **for the lifetime of the project**, and it cannot be reset. For a client only you use, both are nothing. For a client you intend to hand out, the cap is a real asset to spend, and the calculation is different. **Verification itself is the other path and a much longer one.** Gmail and Drive use *restricted* scopes, so the review includes a CASA Tier 2 security assessment: a demo video, a homepage, scope justifications, and months. Worth starting, not worth waiting on: publishing removes the weekly re-authorisation today. --- ## Setup, once Google reorganised this console: the old "APIs & Services → OAuth consent screen" is now the **Google Auth Platform** at [https://console.cloud.google.com/auth](https://console.cloud.google.com/auth), and what used to be one wizard is four separate pages. Enabling the APIs is still elsewhere. | What you are setting | Where it lives now | |---|---| | App name, support email | Auth Platform → **Branding** | | Internal vs External, test users, publishing status | Auth Platform → **Audience** | | **Scopes** | Auth Platform → **Data access** | | The OAuth client ID and secret | Auth Platform → **Clients** | | Enabling the Gmail/Drive APIs | APIs & Services → **Library** | ### 1. Project and APIs [https://console.cloud.google.com](https://console.cloud.google.com), where you create or pick a project. Then enable the APIs, whichever of the seven you mean to connect. ```console $ gcloud services enable gmail.googleapis.com drive.googleapis.com \ sheets.googleapis.com docs.googleapis.com \ calendar-json.googleapis.com tasks.googleapis.com people.googleapis.com \ --project=YOUR_PROJECT ``` Without `gcloud`, it is APIs & Services → **Library**, searching for "Gmail API", "Google Drive API", "Google Sheets API", "Google Docs API", "Google Calendar API", "Google Tasks API", and "People API". **Calendar's service is `calendar-json.googleapis.com`, not `calendar.googleapis.com`.** The plausible name is a different, unrelated service, and enabling it leaves consent succeeding and every call answering 403, the failure this whole page exists to prevent, with a name one word away from the right one. In the Library search box the entry to click is "Google Calendar API". `sheets` and `docs` need the **Drive** API enabled as well as their own. They label a connection by asking `drive/v3/about` who you are, so with Drive disabled the connection authorises and then fails to name itself. **For `gmail_mcp` and `drive_mcp` only**, there are two APIs per product: the service and a separate MCP API that fronts it. Enabling only the first is a trap: the MCP endpoint answers `403` with a perfectly formed JSON-RPC body, and the one sentence explaining why is buried inside it. ```console $ gcloud services enable gmailmcp.googleapis.com drivemcp.googleapis.com --project=YOUR_PROJECT ``` ### 2. Branding App name and a support email. Nothing here is seen by anyone but you. ### 3. Audience **User type.** On a Workspace domain with every account on it, choose **Internal**, then skip the rest of this page's Audience and Data Access steps and go to [Clients](#5-clients). Otherwise choose **External** and continue. See the table above. Add every Google account you intend to connect under **Test users** (up to 100), personal and Workspace alike. An account not listed here cannot authorise. **Then publish the app**, on the same page, under **Publishing status → Publish app**. This is the setting that decides whether your connections survive the week; leaving it in Testing is what expires them after seven days. See [Choose the right path first](#choose-the-right-path-first) for what publishing unverified costs. ### 4. Data access This is where scopes moved to. **Add or remove scopes**, and add these: ``` Gmail https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.settings.basic Drive https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive.file Sheets https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/spreadsheets Docs https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents Calendar https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events Tasks https://www.googleapis.com/auth/tasks Contacts https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/contacts.other.readonly ``` Note `drive.file` is filed under **sensitive**, not restricted, so it appears in a different section of the page from the others. `gmail.modify` is what lets an agent organise mail, and Gmail leaves no way to ask for less. There is no verb for read-state or spam: marking read removes the `UNREAD` label, marking spam adds `SPAM`, archiving removes `INBOX`. All three are label edits, and `modify` is the only scope that permits editing a message's labels. `gmail.labels` sounds narrower but governs the label vocabulary, not its application. The cost is that `modify` also grants send and trash, which is why `lanes link connect` marks it broad and makes you type `y`. It does **not** grant permanent delete; that is `mail.google.com`, which nothing here requests. Leave `gmail.modify` off if you want a read-and-draft mailbox. Everything else keeps working, and the ten organising tools return 403. `gmail.settings.basic` is what lets an agent **block a sender**, and it is worth a separate thought because it is the only grant here that outlives the session. Reporting spam does not need it: that is adding the `SPAM` label under `modify`, and it is what Gmail's own Report-spam button does. Blocking is the other button: a *filter*, a standing rule created once that keeps acting on mail that has not arrived yet. A filter with `addLabelIds: ["TRASH"]` keeps trashing mail after the token expires and after you disable the connection; `lanes link policy deny` removes the tool and cannot remove the rule. `filters.create` and `filters.delete` accept no narrower scope. It is filed under **sensitive** rather than restricted, like `drive.file`, so look for it in that section of the page. It does not grant `gmail.settings.sharing`, so auto-forwarding and delegation stay out of reach. Leave `gmail.settings.basic` off if you do not want standing rules. `filters_list` keeps working, because it accepts `gmail.readonly`, and `filters_create` and `filters_delete` return 403. `spreadsheets` and `documents` are the same shape of decision, for the same reason. Every Sheets and Docs operation is satisfied by `drive.file`, which is already on the list. But `drive.file` means *files this app created*. Its other half, files you pick, arrives through the Google Picker, and there is no picker on an MCP endpoint. So without the broader scope an agent can build a spreadsheet and maintain it indefinitely, and cannot open the one you made in the browser last week. Leave them off if that is the trade you want: `sheets` and `docs` keep working on their own files, and return 403 on yours. Add them and an agent can edit anything of that type in the account, which is why `lanes link connect` marks both broad and makes you type `y`. Neither grants `auth/drive`; files that are not spreadsheets or documents stay read-only. `calendar.events` is the same shape again, one product along. It reaches every event on every calendar you can see, and it reaches nothing else. It cannot create a calendar, delete one, or change who it is shared with. Those are `auth/calendar`, this product's `mail.google.com`, and nothing here asks for it. Calendar does publish two narrower scopes, `calendar.events.owned` and `calendar.app.created`, and neither is usable: Google's own API description does not list them against these operations, so requesting one grants nothing the calls accept. Leave `calendar.events` off and `calendar` becomes read-only: listing, searching, and free/busy still work, and creating or moving an event returns 403. `calendar.readonly` is not optional: two operations accept nothing narrower, and they are the list of your calendars and free/busy itself. `tasks` is the one scope on this page with no argument behind it, because Google publishes no alternative. Tasks has exactly two scopes, `tasks` and `tasks.readonly`, so adding a single task means holding write and delete over every list in the account. Leave it off and there is no Tasks provider. The read-only scope is not requested, because a to-do list you cannot write to is not what anyone connected it for. What bounds it instead is the tool surface: `tasklists.delete` is not vendored, so nothing exposed here can destroy a list and the tasks inside it. Contacts asks for nothing broad. Both of its scopes are read-only, and there are two because Google keeps contacts in two places: `contacts.readonly` is the address book you curated, and `contacts.other.readonly` is where Gmail files an address you have written to but never saved, which is where most lookups actually land. The write scope, `contacts`, permanently deletes and is not requested. The two MCP providers use the shorter list, `gmail.readonly` and `gmail.compose` only. They *advertise* more, adding `gmail.metadata` and `mail.google.com` (read, send, and **permanently delete**); Drive adds `auth/drive`. Requesting the full advertised set was tested against the live service and changed nothing, because what gates those providers is Developer Preview enrolment rather than scope, so they stay at what Google documents. `lanes link connect` prints whichever list applies, in plain words, before the browser opens. ### 5. Clients **Create OAuth client → Application type: Desktop app.** Then copy the client ID and secret. `lanes link connect gmail` asks for them once per profile and stores them encrypted; only `_ref` pointers ever reach the config file. --- ## Why Desktop app, even for Cloud Run The obvious worry is that a deployed instance needs a "Web application" client with a public redirect URI. It does not, and this is the payoff of a decision made early ([ADR-005](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/005-oauth-connection-flow.md)): **the OAuth flow runs in the CLI, never on the server.** ``` lanes link connect gmail --workspace cloud → browser and loopback listener are on YOUR machine → the refresh token is written into the cloud workspace's credential store → the Cloud Run instance only ever USES that token; it never authorises ``` So the redirect URI is `http://127.0.0.1:<port>/callback` on your laptop whether the server ends up local or deployed. The deployment workspace does not change the client type. That is also why there is no public callback URL to register, no domain to verify, and no inbound path to the server: a deployed instance exposes no administrative surface at all. Google's own MCP documentation says "Web application", and tells you to register a redirect URI belonging to **the agent host**, such as `https://claude.ai/api/mcp/auth_callback` for Claude, `https://antigravity.google/oauth-callback` for Antigravity. That is right when the *host* runs the OAuth flow and holds the tokens. Here it does not. Lanes Link runs the flow itself and holds the tokens, which is the whole point: the agent gets a policy-filtered endpoint, never your Google credentials. So the redirect belongs to this CLI on loopback, and **Desktop app** is the correct type: the one client type that accepts any loopback port without pre-registration. --- ## Connect ```console $ lanes link connect gmail --profile personal --workspace local # asks for client id + secret, then opens the browser $ lanes link connect gmail --profile personal --workspace local # second account, straight to the browser $ lanes link connect drive --profile personal --workspace local # reuses the same client; no prompts $ lanes link connect sheets --profile personal --workspace local # ditto, but do step 1 and step 4 for Sheets first $ lanes link connect docs --profile personal --workspace local $ lanes link connect calendar --profile personal --workspace local $ lanes link connect google_tasks --profile personal --workspace local $ lanes link connect contacts --profile personal --workspace local $ lanes link connect gmail_mcp --profile personal --workspace local # only if you are enrolled in the preview ``` **Adding a Google product to a profile that already has one is where this trips people up.** The client ID and secret are shared, so there is nothing to type. The console work is not shared. Each product needs its own API enabled ([step 1](#1-project-and-apis)) and its own scopes added ([step 4](#4-data-access)), and skipping that fails in the two ways this page keeps warning about: a scope you never registered is refused at the consent screen, and an API you never enabled consents perfectly and then answers `403` on every call. `lanes link connect` reprints the setup steps the first time you connect each product, for exactly this reason. It does not reprint them on a re-authorisation. At the consent screen you will see **"Google hasn't verified this app"**. That is expected for an unverified Testing app. Click **Advanced → Go to \<app name\> (unsafe)** and continue. It is your own app, registered in your own project, and the credentials never leave your machine. Each run adds one account. The client ID and secret are asked for once per *profile*, not once per account: all your Google connections authorise against the same registered client, which is what the `oauth_apps` block in your config exists for, and what its presence tells Lanes Link to keep using instead of the hosted client. --- ## Connecting with a service account key The one route where nothing expires, because nothing consented. Pick it at the prompt, or: ```console $ lanes link connect drive --auth service_account ``` A service account is an identity in its own right. It has a Drive and a calendar; it has no mailbox, no contacts and no task lists. That single fact decides everything else about this route. | Provider | Works with a key alone | Needs domain-wide delegation | |---|---|---| | `drive`, `sheets`, `docs`, `calendar` | **yes**, reaching what you share with it | only to reach the whole account | | `gmail`, `contacts`, `tasks` | no, there is nothing there to reach | **yes**, and Workspace only | | `gmail_mcp`, `drive_mcp` | not offered, because Google's MCP servers take a client rather than an assertion | n/a | ### The key One key covers every Google provider on a profile, so this is done once. In the Cloud console: **IAM & Admin → Service Accounts → Create**, then **Keys → Add key → Create new key → JSON**. Grant it no project roles. That page governs Google Cloud resources, and nothing here is one. `connect` asks for the **path** to the downloaded file. It reads it once and stores the contents, so the file itself is not needed afterwards and can be deleted. Pasting the contents works too. ### Sharing, for Drive, Sheets, Docs and Calendar The key's address ends in `.iam.gserviceaccount.com` and is printed when it is stored. Share what you want reachable with it, exactly as you would with a colleague: a Drive folder, one spreadsheet, a calendar. **Nothing else in the account is reachable, including files the same person owns.** That is the point of this route and it is also the answer when something appears to be missing: it has not been shared yet. Leave the "account to act as" prompt blank and the key acts as itself. ### Delegation, for Gmail, Contacts and Tasks These need a Google Workspace administrator, and a personal Google account cannot do it at all. For mail specifically there is another way in. See [Gmail over IMAP](/docs/link/google#gmail-over-imap). Contacts and Tasks have none. Copy the service account's numeric **Unique ID** from its Details tab. That is the client ID, not the email address. Then, in the Workspace Admin console: **Security → Access and data control → API controls → Domain-wide delegation → Add new**. Paste that ID, and paste the provider's full scope list into the scopes field, comma-separated, in one go. `connect` prints the exact list to paste. Paste all of it: a partial list is refused identically to a missing one, and the refusal does not say which scope was short. Delegation can take a few minutes to take effect. If the first attempt is refused with `unauthorized_client`, wait and run it again. Nothing was stored. Then answer the "account to act as" prompt with the address whose mail, contacts or tasks you want. It is required here: a key acting as nobody authenticates perfectly and then reads every mailbox as empty, which is a wrong answer that looks like a right one. ### What it costs A key does not expire, which is the feature and also the whole of the risk: there is no consent to withdraw and no token to age out, so a leaked key is good until somebody deletes it in the console. Treat it as you would a password, and prefer sharing over delegation where sharing will do: one shared folder is a much smaller grant than the right to act as you. --- **Back to:** [Connecting Google](/docs/link/google), which is the short version and what most people need. Source: https://lanes.sh/docs/link/google-own-client --- # Google verification: what goes in the console > The scope justifications Google's review asks for, and why each scope is the narrowest that works Google Auth Platform → **Data access** refuses a submission until every sensitive and restricted scope is accounted for: the feature that needs it, what becomes of the data, and **why a narrower scope will not work**. None of them are settings. They are free text on a console form, and a submission is rejected on what they say. This file is that text, kept here rather than only in the form, for three reasons. Verification is annual, so next year's re-submission starts from what was accepted this year rather than from memory. A scope and the argument for it change together, and a justification that outlives the capability it was written for is a request for access the application no longer uses, which is one of the things reviewers look for. And `specs.test.ts` can then check that this file and the manifests name the same scopes, which is the only mechanism that keeps the two honest. Everything below is about the **hosted client**, the one Lanes operates, which [ADR-028](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/028-a-hosted-oauth-client-is-the-default.md) made the default. An operator who runs `lanes link connect <provider> --own-client` registers their own and needs none of this; [`setup/google.md`](/docs/link/google) is that path. **Verification is not what makes a connection last.** The two are separate settings and it is worth being exact, because the confusion sends people here for a problem this page does not solve: the seven-day refresh-token expiry follows an OAuth client's *publishing status*, and publishing an unverified client removes it. What verification buys is the removal of the unverified-app screen and of the lifetime 100-new-user cap, which is what a client handed to other people needs, and is unrelated to how long any one connection survives. An operator who wants neither can authenticate with a service account key instead ([ADR-038](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/038-a-key-is-the-second-way-into-an-account.md)), which requires no verification of any kind because nothing consents on a user's behalf. ## Where each field lives The console used to ask three questions of each of the twelve scopes on its own form. It now groups them, with **one box for all seven sensitive scopes together and one box per restricted API family**, and merges the justification and the intended-data-usage statement into a single question, capped at **1000 characters**. | Field | Where | What it has to contain | |---|---|---| | How will the scopes be used? | **Your sensitive scopes**, one box, all seven | Per scope: the feature that needs it, and **why a narrower scope will not work**, an explanation naming what would break. Omitting the second half is the common rejection. | | How will the scopes be used? | **Your restricted scopes** → **Drive scopes** | The same for `drive.readonly`, plus what becomes of the data. | | How will the scopes be used? | **Your restricted scopes** → **Gmail scopes** | The same for the four Gmail scopes. | | What features will you use? | A multi-select above each restricted family's box | Google's own categories. Drive: **Drive productivity** alone. Gmail: **Email client** and **Email productivity**. Claiming a category the application does not have is a rejection by itself, so "Select all" is the wrong answer to a question that offers it. | | Demo video | **Not on this page.** The final submission step | One YouTube URL, unlisted is fine, covering every scope. | The prompt under each box asks for three things, "why you need these scopes, how you will use them, and why more limited scopes aren't sufficient", which is the old justification and the old intended data usage in one field. Both have to come out of the same 1000 characters. `openid` and `email` are added on the brokered path at connect time rather than declared in a manifest, and the console does not list them. Nothing is needed for either. ## The scopes Thirteen, and this block is what the test reads. Adding a scope to a manifest without adding it here fails the build, and so does the reverse. {/* scopes:begin */} | Scope | Class | Fields needed | |---|---|---| | `https://www.googleapis.com/auth/gmail.readonly` | restricted | yes | | `https://www.googleapis.com/auth/gmail.compose` | restricted | yes | | `https://www.googleapis.com/auth/gmail.modify` | restricted | yes | | `https://www.googleapis.com/auth/gmail.settings.basic` | restricted | yes | | `https://www.googleapis.com/auth/drive.readonly` | restricted | yes | | `https://www.googleapis.com/auth/drive.file` | neither | **none**, being neither sensitive nor restricted | | `https://www.googleapis.com/auth/documents` | sensitive | yes | | `https://www.googleapis.com/auth/spreadsheets` | sensitive | yes | | `https://www.googleapis.com/auth/calendar.readonly` | sensitive | yes | | `https://www.googleapis.com/auth/calendar.events` | sensitive | yes | | `https://www.googleapis.com/auth/tasks` | sensitive | yes | | `https://www.googleapis.com/auth/contacts.readonly` | sensitive | yes | | `https://www.googleapis.com/auth/contacts.other.readonly` | sensitive | yes | {/* scopes:end */} The class is the lock column in the console, and it decides one thing beyond the paperwork: the five restricted scopes are what put a security assessment in question. See [the assessment](#the-security-assessment-and-the-question-that-decides-it) below. Two refusals are worth stating in the submission because reviewers look for the opposite. Lanes Link does not request `https://mail.google.com/`, which is full mailbox access including permanent deletion, and does not request unrestricted `drive`. Permanent deletion is not offered anywhere: `messages.delete`, `threads.delete` and `files.delete` are deliberately absent, and trash is the recoverable form of the same intent. ## What goes in the console today Three boxes, three texts, each measured against the 1000-character cap and each carrying all three of the things the prompt asks for. This is what is pasted. Everything from [the shared handling paragraph](#the-shared-handling-paragraph) onward is the long-form reasoning these were condensed out of, kept because it is what answers a reviewer's follow-up and because it is where the argument for a scope is maintained when the scope changes. All three end their opening paragraph by volunteering that the hosted client is optional. That sentence is there deliberately: a reviewer assessing restricted scopes is deciding whether this application can reach user data through a third-party server, and the honest answer is easier to accept when it arrives unprompted and with the escape hatch attached. It is the same argument [the security assessment](#the-security-assessment-and-the-question-that-decides-it) section makes at length, compressed to one clause. Do not drop it to buy characters for something else. ### Your sensitive scopes (995 characters) > Lanes Link is an open-source MCP endpoint the user runs on their own machine, so the AI agent they chose can act on their Google data when they ask. Requests go from that machine straight to Google and back: no Lanes server in that path, no copy kept, never sold, advertised against, or used to train AI. Our shared OAuth client is optional; --own-client uses their own. > > Calendar (calendar.readonly, calendar.events): show their schedule, answer "when am I free", and create or reschedule events they ask for. The read-only forms cannot create an event. > > Docs and Sheets (documents, spreadsheets): read a doc or sheet to answer a question about it and make the edits they ask for. The .readonly forms cannot write. > > Tasks (tasks): list their tasks, add one, mark one done. Google publishes no narrower write scope. > > Contacts (contacts.readonly, contacts.other.readonly): turn a name into an email address when they say "email Bob about the invoice". Read-only, and only a search they asked for. ### Your restricted scopes → Drive scopes (994 characters) **What features will you use?** *Drive productivity*, and nothing else. Nothing copies Drive content anywhere for retention, so not *Drive backup*; nothing mirrors Drive to a local folder, so not *Drive sync client*. > Lanes Link is an open-source MCP endpoint the user runs on their own machine so their agent can find and read the files they name. File content goes from that machine straight to Google and back: no Lanes server in that path, no copy kept, never sold, advertised against, or used to train AI models. Our shared OAuth client is optional; --own-client uses the user's own and removes us from the credential exchange. > > drive.readonly covers files.list (search), files.get, files.export (Google-native files have no downloadable bytes), permissions.list (sharing) and about.get (quota). > > Nothing narrower works. drive.file is requested alongside it and bounds every write, but alone reaches only files this app created or the user picked in the Google Picker - and there is no picker: this is a command-line endpoint with no UI. A file is named by title in conversation, so it must be findable by search - files.list. drive.metadata.readonly returns no content. Unrestricted drive is not requested. ### Your restricted scopes → Gmail scopes (992 characters) **What features will you use?** *Email client* and *Email productivity*. Reading, searching, drafting and sending is client behaviour; archiving, labels and filters is productivity. The scope set spans both, and picking one leaves the other half unexplained: client alone makes `settings.basic` look stray, productivity alone weakens `compose`. Nothing about backup, migration, monitoring, compliance, anti-spam or CRM applies. > Lanes Link is an open-source MCP endpoint the user runs on their own machine so their agent can read, write and organise their mail. Mail goes from that machine straight to Google and back: no Lanes server in that path, no copy kept, never sold, advertised against, or used to train AI models. Our shared OAuth client is optional; --own-client uses the user's own and removes us from the credential exchange. > > gmail.readonly: messages/threads.list and .get, labels, drafts, attachments.get, filters.list. gmail.metadata returns no body, so "summarise this thread" fails. > gmail.compose: drafting and sending. gmail.send cannot create or revise a draft, and drafting keeps the user in the loop. > gmail.modify: read/unread, archive, spam, folders, labels. Gmail has no narrower verb: each is messages.modify, since a folder is a label. > gmail.settings.basic: filters.create/delete, to block a sender. No other scope accepts it. > > mail.google.com is not requested; no permanent deletion, only trash. Each was cut to fit, and what got cut is the same thing every time: the handling paragraph below shrank to one clause, and the per-operation detail shrank to what a reviewer reads rather than what an implementer needs. What was kept in all three is the narrower-scope argument, one clause per group, because the prompt asks for it by name and it is the half a submission is rejected on. The sensitive box is deliberately the plainest of the three. It carries seven scopes across four products, and a reviewer meeting it has no reason to know an API method name, so it names the product, says what the user gets, and gives the narrower-scope answer in a clause. It also does not list the scopes that were *not* requested. That argument is real and it is made at length below, but in 1000 characters shared by seven scopes the space goes to what is being asked for. The restricted boxes still make it, because `drive.file` versus `drive.readonly` and the absence of `mail.google.com` are the questions those reviewers actually arrive with. ### Additional info (996 characters) A fourth box, at the end of the submission after the scope justifications and the video link, and the only one on the form that is optional. Three things go in it and they only just fit. [The broker](#the-security-assessment-and-the-question-that-decides-it) is the first, because there is nowhere in a scope justification it fits and being asked is worse than volunteering. The handling statement is the second. The scope boxes carry a one-clause form, and this is where the Limited Use commitment gets made in Google's own words, which is worth the characters it costs. The third is that Google asks outright, in the box's own prompt, for "the project IDs of any other projects that use OAuth": for this application that is the sign-in project [ADR-031](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/031-sign-in-and-data-access-are-separate-projects.md) keeps separate, and a question the form asks by name reads as an omission when it goes unanswered. Two things did not fit and are not missed. The install command is one line in the README and the repository is linked in the first sentence. The privacy policy URL has a dedicated field elsewhere on the same submission, so the pointer here is a cross-reference rather than the link itself. > Lanes Link is open-source software the user runs on their own machine: github.com/lanes-sh/link. Requests go from that machine directly to Google and back: Lanes operates no server in that path and holds no copy. Google user data is never used for advertising, never sold or transferred, never read by a person at Lanes, and never used to develop, improve or train generalised or non-personalised AI/ML models. See section 7 of our privacy policy. > > The only things reaching a Lanes server are the OAuth authorization code and refresh token, in transit through our token broker and not retained, plus a salted hash of the account identifier, used to enforce the per-account limit on the shared client. An installed app cannot hold a client secret, which is the only reason the broker exists. --own-client registers the user's own client and removes us from that path too. > > Other OAuth projects: sign-in (for desktop app) is separate, in project lanes-sh, requesting only openid, email and profile. ## The shared handling paragraph This is the full statement of what becomes of the data, and it no longer fits in the console: the grouped boxes are 1000 characters and the narrower-scope argument has to win that space. Each of the three texts above carries a one-clause form of it instead, and this is what that clause compresses. It is kept whole because it is what to send if a reviewer asks, and because the privacy policy has to stay consistent with it. > Lanes Link is software the user runs on their own machine. The request goes from that machine > directly to Google and the response returns to it. Lanes operates no server in that path and > holds no copy. The response is handed to the AI agent the user chose and configured, at the > moment they asked for it, under per-capability permissions that deny everything by default. > Google user data is never used for advertising, never sold or transferred to a third party, > never read by a person at Lanes, and never used to develop, improve or train generalised or > non-personalised artificial intelligence or machine learning models. The only things that reach > a Lanes server are the OAuth authorization code and the refresh token, in transit through the > token broker and not retained, and a salted irreversible hash of the account identifier, used to > enforce the per-account limit on the shared client. The full statement is at > [https://lanes.sh/privacy](https://lanes.sh/privacy), section 7. ## Gmail Everything from here on is long-form: one section per scope, at the length the argument actually takes. None of it is pasted as-is. [What goes in the console today](#what-goes-in-the-console-today) is what goes in the boxes. This is where the reasoning lives, where a reviewer's follow-up is answered from, and what has to be edited when an operation is added to `SELECTION`. ### `gmail.readonly` **Justification.** Reading and searching mail is the primary feature: the user asks their agent what a message said, what a thread concluded, or which messages match a query, and the agent answers from Gmail. This scope covers `messages.list` and `messages.get` (search over Gmail's own `q` syntax, then retrieval by id), `threads.list` and `threads.get`, `labels.list` and `labels.get`, `drafts.list` and `drafts.get`, `messages.attachments.get`, `getProfile` for the address the connection belongs to, and `settings.filters.list`. Nothing narrower works. `gmail.metadata` returns headers and label ids and no body or attachment, so "what does this say" and "summarise this thread", the two things most often asked, cannot be answered at all. `gmail.addons.current.message.readonly` is scoped to the single message an add-on is currently open on; Lanes Link is not a Gmail add-on, runs in a terminal, and has no such context. **Intended data usage.** Message headers, bodies and attachments, thread structure, label names, existing filter definitions, and the account's own address are returned over TLS to the user's machine. *(Then the shared handling paragraph.)* ### `gmail.compose` **Justification.** Drafting and sending. `gmail.send_message` assembles the RFC 2822 message and posts it: as a draft when the user asks for one, as a send when they ask for that; `drafts.send` sends a draft that already exists and `drafts.delete` discards one. `gmail.send` is narrower and insufficient: it can send but cannot create, revise or discard a draft, and drafting is what keeps a person in the loop. The intended flow is that the agent writes, the user reads it in Gmail on their phone or in the browser, and the send is a second and separate act. Removing the draft step would make every agent-composed message an immediate send, which is worse for the user and not what is being built. `gmail.insert` and `https://mail.google.com/` are broader and are not requested. **Intended data usage.** The content of a message the user asked to be written, meaning recipients, subject, body, and any attachment they named, is transmitted to Gmail to create a draft or send it. Attachments are resolved from a path or URL by the endpoint on the user's own machine and streamed to Google; the endpoint records the filename, size, media type and SHA-256 in the local audit log and does not record the content. *(Then the shared handling paragraph.)* ### `gmail.modify` **Justification.** Organising mail: marking read and unread, archiving, reporting spam, moving between folders, and maintaining the label vocabulary itself. This covers `messages.modify`, `messages.batchModify`, `threads.modify`, `messages.trash` and `untrash`, `threads.trash` and `untrash`, and `labels.create`, `labels.update` and `labels.delete`. There is no narrower scope, because Gmail has no separate verb for any of it. Marking read is removing the `UNREAD` label; archiving is removing `INBOX`; reporting spam is adding `SPAM`; and moving a message to a folder is adding that folder's label id, because a Gmail folder *is* a label. Every one of those is `messages.modify`. `gmail.labels` governs the label vocabulary, creating and deleting the labels themselves, and not their application to a message, so it covers the last of those operations and none of the others. Permanent deletion is not part of this and is not offered: `messages.delete` requires `https://mail.google.com/`, which is not requested, and trash is the recoverable form of the same intent. **Intended data usage.** Message ids, thread ids and label ids are transmitted to Gmail to apply the change the user asked for, and label names are transmitted when the user asks for a new label. Message content is read under `gmail.readonly` rather than here. *(Then the shared handling paragraph.)* ### `gmail.settings.basic` **Justification.** Blocking a sender. `settings.filters.create` and `settings.filters.delete` accept this scope and no other, and a filter is the only thing in Gmail that acts on mail which has not arrived yet. It is a genuinely different feature from reporting spam: that trains the classifier on a message already received and is the label edit under `gmail.modify` above, and it does nothing about the next message from the same sender. Reading the filters that already exist is `settings.filters.list`, which accepts `gmail.readonly` and does not need this scope. Nothing else in Lanes Link uses it: no signature, vacation responder, forwarding address, send-as alias, or IMAP/POP setting is read or written, and no capability exists that could. **Intended data usage.** The criteria of a filter the user asked to create, in the common case a sender address, and the id of a filter they asked to delete are transmitted to Gmail. *(Then the shared handling paragraph.)* ## Drive, Docs and Sheets ### `drive.readonly` **Justification.** Finding and reading the files the user points the agent at: `files.list` for search, `files.get` for metadata, `files.export` to render a Google-native file to a portable format because its bytes are not directly downloadable, `permissions.list` for who a file is shared with, and `about.get` for storage quota. `drive.file` is requested alongside this and is not sufficient on its own. It reaches only files this application created and files the user selected through the Google Picker, and there is no picker here. Lanes Link is a command-line MCP endpoint with no UI to host one: the user names a file by its title in conversation with their agent, which means it has to be findable by search, which is `files.list` and needs this scope. `drive.metadata.readonly` returns names and properties but no content, so "read this document to me" and "what does this spreadsheet say" both fail. Unrestricted `drive` is not requested. **Intended data usage.** File names and metadata, sharing information, storage quota, and the contents of files the user asks about are returned to the user's machine. *(Then the shared handling paragraph.)* ### `drive.file` No fields are required, because it is neither sensitive nor restricted. It is listed here because it is requested, and because it is load-bearing in the justification above: every Drive **write** Lanes Link performs is bounded by it. `files.create`, `files.update`, `files.copy` and `permissions.create` reach only files this application itself made, which is why there is no rename-anything or share-anything capability. ### `documents` **Justification.** Reading and editing Google Docs. `documents.get` returns the document as a structure, which is how the index to edit at is found, and `documents.batchUpdate` is the entire Docs write surface: there is no per-operation scope and no `values`-style shortcut. `documents.readonly` covers the first of the two and none of the editing, and editing is the feature. Drive cannot substitute for it. Drive treats a Google-native file as an opaque blob, and the only route back in would be `files.update` with media, a whole-file replace through import conversion, which discards headings, formatting, comments and suggestions. Paragraph-level editing exists solely in the Docs API. **Intended data usage.** Document content is read to answer the user's question and to locate the position of an edit, and the text the user asked to insert or change is transmitted to Google. *(Then the shared handling paragraph.)* ### `spreadsheets` **Justification.** Reading and writing cell values and sheet structure: `spreadsheets.get` for tabs, named ranges and dimensions; `values.get` and `values.batchGet` to read a range; `values.update`, `values.batchUpdate`, `values.append`, `values.clear` and `values.batchClear` to write one; `sheets.copyTo` to copy a tab into a different spreadsheet; and `spreadsheets.batchUpdate` for structural edits: tabs, frozen rows, formatting, charts and conditional formats. `spreadsheets.readonly` covers the reads and none of the writes, and writing a range is the point: "read this range, work out the totals, write them back" is the ordinary request. As with Docs, Drive cannot substitute, because no Drive operation edits a cell, and a whole-file replace through import conversion would discard formulas, formatting and comments. **Intended data usage.** Cell values and spreadsheet structure are read, and the values and structural changes the user asked for are transmitted to Google. *(Then the shared handling paragraph.)* ## Calendar, Tasks and Contacts ### `calendar.readonly` **Justification.** Two operations accept nothing narrower, and both are foundational rather than incidental. `calendarList.list` is how the agent learns which calendars exist and what time zone the primary one is in. Without it every event created lands in the wrong zone, because there is nowhere else to read that from. `freebusy.query` answers "when am I free", across calendars whose *contents* the token may not be permitted to read; it returns busy intervals rather than events, and it is the primitive that scheduling anything depends on. `calendar.events` grants access to events and neither of these two. Full `calendar` is not requested: nothing in Lanes Link creates, deletes or re-shares a calendar, and every operation that would need it is deliberately absent. **Intended data usage.** The list of the user's calendars, their time zones, and busy/free intervals are returned to the user's machine. `freebusy` returns intervals, not event contents. *(Then the shared handling paragraph.)* ### `calendar.events` **Justification.** Reading and changing events: `events.list`, `events.get` and `events.instances` to read, the last because `list` does not expand a recurring event unless asked, and `events.insert`, `events.patch`, `events.delete` and `events.move` to change one. `calendar.events.readonly` covers the reads only, and creating the event the user asked for is the feature. `calendar.events.owned` is narrower still but excludes events on calendars shared with the user, which for anyone working with other people is a large part of a real calendar. Full `calendar` is deliberately not requested. Note also that there is no PUT here: `events.update` replaces the resource, so an agent that read an event, changed the title and sent it back would silently drop attendees, reminders, recurrence and conferencing, only `patch` is offered. **Intended data usage.** Event titles, times, attendees, locations and descriptions are read to answer the user's question, and the details of an event the user asked to create or change are transmitted to Google. *(Then the shared handling paragraph.)* ### `tasks` **Justification.** Reading and writing task lists: `tasklists.list`, `tasks.list` and `tasks.get` to read; `tasklists.insert` and `tasklists.patch`, `tasks.insert`, `tasks.patch`, `tasks.delete` and `tasks.move` to write. The Tasks API publishes exactly two scopes, `tasks` and `tasks.readonly`. The read-only one covers the three reads and none of the writes, and adding a task is the feature, so `tasks` is the narrowest scope that works, and there is no per-operation alternative to prefer. Two operations it would permit are deliberately not offered: `tasklists.delete` destroys a list and every task in it and Tasks has no trash, and `tasks.clear` hides every completed task in a list in one call while naming none of them, which would leave an audit entry that could not say what it did. **Intended data usage.** Task titles, notes, due dates and completion state are read, and the tasks and lists the user asked to create or change are transmitted to Google. *(Then the shared handling paragraph.)* ### `contacts.readonly` **Justification.** Turning a name into an address. `people.searchContacts` is what makes "email Bob about the invoice" resolvable at all, and `people.getBatchGet` retrieves the contacts search returned. The People API offers two read paths into a user's own contacts, `contacts.readonly` and full `contacts`, and the second is a write grant that is not requested, so this is the narrowest scope that works. Enumeration is not offered: `people.connections.list`, which would list every contact, is deliberately absent, so the only way a contact is read is that the user asked for one by name. **Intended data usage.** The names, email addresses and phone numbers of contacts matching a search the user asked for are returned to the user's machine. Contacts are never enumerated, bulk-exported or copied anywhere. *(Then the shared handling paragraph.)* ### `contacts.other.readonly` **Justification.** "Other contacts" is the separate store where Gmail files an address that has been written to but never saved as a contact, and for the request this exists to serve, "reply to the person who sent the quote", it is more often than not where that address actually is. `people.otherContacts.search` accepts this scope and nothing else; `contacts.readonly` does not reach the store at all. It is read-only and has no narrower form. **Intended data usage.** The names and email addresses of auto-saved addresses matching a search the user asked for are returned to the user's machine. *(Then the shared handling paragraph.)* ## The demo video **One** unlisted YouTube video covering every scope. Not one per scope: a single recording is required to cover all of them, which is the whole reason it is worth recording last, after the scope set has stopped moving. The URL field is no longer on the Data access page and is not asked for per scope; it is collected once at the final submission step, after the boxes above are filled. There is no way to avoid it here. A video is required for *sensitive* scopes as well as restricted ones, so dropping all five restricted scopes would still leave seven that need it; the only routes that skip it are the ones that skip verification altogether, meaning Internal, Testing, personal use, domain-wide install, and a service account key, and Testing is the status [ADR-028](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/028-a-hosted-oauth-client-is-the-default.md) exists to escape. Four things are asked for, and the bar is lower than "demo video" suggests. It is a screen recording of the tool being used, not a produced asset: - the OAuth grant process **in English**; - the consent screen displaying the application name correctly; - the browser address bar, on that consent screen, showing the OAuth client id; - the functionality each requested scope enables, actually exercised. Shot list, connect then consent then exercise, one provider at a time: 1. `https://lanes.sh/link`, what the application is and who runs it. 2. `lanes link connect gmail`. The consent screen, with the application name and all four Gmail scopes legible and the address bar showing the client id. Approve. Then: search and read a message, archive one and mark another unread, draft a message and send it, create a filter that blocks a sender. 3. `lanes link connect drive`. List and read a file, then create one, so `drive.readonly` and `drive.file`. 4. `lanes link connect sheets` and `lanes link connect docs`. Read a range and write it back; read a document and edit a paragraph. 5. `lanes link connect calendar`. Show free/busy and the calendar list, then create an event. 6. `lanes link connect google_tasks`. List, then create and complete one. 7. `lanes link connect contacts`. Search a name to an address, including one that resolves out of Other contacts. **Every account, address, file, event and contact on screen must belong to a throwaway test account.** The video becomes a public URL attached to the project. The rule against real identifiers in this repository exists because it was broken once and cost eighty-nine commits to undo; a video is worse, because there is no history to rewrite. ## Before submitting Five checks, in the order they bite. The first is the only one that cannot be undone. **Submit the project that holds the Google-data client, not the one that signs people in.** [ADR-031](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/031-sign-in-and-data-access-are-separate-projects.md) is the argument; the operational half is that a submission enters *every* client in that project into the review, and that the unverified-app cap is spent for the lifetime of a project and cannot be reset. Submitting the wrong one is not a mistake that gets corrected later. **Confirm the sign-in project's Data access page still lists nothing but `openid`, `email` and `profile`.** A sensitive scope registered there is what puts an unverified-app screen in front of someone who only clicked "sign in", and no test in any repository can see it. This is the check that keeps the two levels apart, and it is worth repeating whenever a scope is added anywhere. **Point the privacy policy field at [https://lanes.sh/privacy](https://lanes.sh/privacy), with no query string.** That page exists for this field, and says so in its own source: review tooling will not follow a query parameter into a client-side tab. A `?tab=` form resolves to the same document for a human and is a gratuitous chance for a reviewer to land somewhere unintended. **Fill all three boxes and both feature dropdowns** from [What goes in the console today](#what-goes-in-the-console-today). The console refuses the submission until every sensitive and restricted scope is covered by the box it sits under; `drive.file` needs nothing, and the video URL is asked for later. **Say what the broker is before anyone asks.** The next section is why. ## The security assessment, and the question that decides it The five restricted scopes are what raise it. Google's stated trigger is narrow, and it is worth reading precisely rather than assuming: > Every app that requests access to Google users' restricted data **and has the ability to access > data from or through a third-party server** must go through a security assessment. Lanes Link's answer to the first half is yes and to the second half is *almost* no, and the gap is the broker. Mail, files, documents, events, tasks and contacts move directly between the user's machine and Google; no Lanes server is in that path and there is no copy. That is what [`security.md`](/docs/link/security) states and what the architecture enforces. But [ADR-028](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/028-a-hosted-oauth-client-is-the-default.md) puts the authorization code and every subsequent refresh through the Lanes API, because an installed application cannot hold a confidential client secret. So the API handles a credential that *could* be used to reach restricted data, even though it never does. Whether that constitutes "the ability to access data through a third-party server" is a question of fact about the broker, and the answer is the difference between a review measured in weeks and one measured in months plus an annual third-party assessment. Put it in the submission notes explicitly, meaning what the broker holds, what it does not, that no user data passes through it, and that `--own-client` removes it from the path entirely, rather than leaving the reviewer to infer it. `security.md` already records the honest version as `credentials.exchange-is-local: NOT-GUARANTEED`, and saying so first is better than being asked. Do not wait on the answer. If an assessment is required it is the critical path, and everything above can proceed alongside it. ## Keeping this true `src/providers/google/specs/specs.test.ts` checks both directions against the block above: every scope a manifest requests appears here, and every scope named here is requested by some manifest. The second is the one that matters for a submission: a scope dropped from a manifest but left in the console text is a standing request for access the application no longer uses. What the test cannot check is whether the *prose* still describes what the capability does. When an operation is added to `SELECTION` in [`specs/vendor.ts`](https://github.com/lanes-sh/link/blob/main/src/providers/google/specs/vendor.ts), the justification for the scope it lands under is part of the change, and so is re-recording the demo video if the operation is user-visible. Source: https://lanes.sh/docs/link/google-verification --- # Creating a provider > Add your own integration **Start here: most providers are a YAML file, and this page is mostly not about them.** A provider declares *how to reach a vendor*, not *what the vendor can do* ([ADR-008](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/008-connectors.md)). Capabilities are discovered, from the vendor's own MCP server or from an OpenAPI document, so there is nothing per-endpoint to write. `lanes link connect custom <id> --connector <kind> --auth <method>` writes one of these for you and connects it in the same command, asking for anything it needs ([ADR-048](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/048-declaring-a-provider-from-the-fixed-lists.md)). This page is what it writes, and what to edit afterwards. Drop one of these in `<workspace>/data/<profile>/providers.d/*.yaml` and it registers with no code and no rebuild, for that profile, which is the only one that can reach it ([ADR-030](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/030-a-profile-owns-its-skills-and-manifests.md)): ```yaml # Any MCP server: a vendor's, a colleague's, your own. id: acme name: Acme connector: { kind: mcp, endpoint: https://mcp.acme.com/mcp } auth: { kind: oauth, registration: dynamic } ``` ```yaml # An MCP server that will not register a client for you, so you paste a token instead. # `headers` is for configuration the *server* offers; the credential is never one of them. id: acme_token name: Acme connector: kind: mcp endpoint: https://mcp.acme.com/mcp headers: { X-Acme-Toolsets: "issues,repos" } auth: { kind: bearer } setup: docs_url: https://acme.com/settings/tokens steps: ["Generate a token at https://acme.com/settings/tokens and copy it."] prompts: - { key: token, label: Acme API token, secret: true, scope: connection } ``` ```yaml # Any REST API with a spec. The method decides the bundle: GET/HEAD read, the rest write. id: mything name: My Thing connector: kind: http base_url: https://api.mything.com openapi: ./mything.json operations: include: ["*Account*", "*Payment*"] # keeps a large spec's tool list reasonable auth: kind: header header: X-API-Key setup: docs: "Generate a key at https://mything.com/settings/api" prompts: - key: api_key label: My Thing API key secret: true scope: connection # one key per account, so the ref derives ``` ```yaml # Any mailbox. There is no spec to read, because IMAP describes its extensions # but never its operations, so the capability set comes from the protocol. id: fastmail name: Fastmail connector: kind: imap host: imap.fastmail.com smtp: { host: smtp.fastmail.com, port: 465, starttls: false } auth: { kind: basic } identity: { kind: connector } # the account is the name the server accepted setup: docs: "App password: https://app.fastmail.com/settings/security/apppw" prompts: - { key: username, label: Email address, scope: connection, field: username } - { key: password, label: App password, secret: true, scope: connection, field: password } ``` ```yaml # Any CalDAV or CardDAV server: Nextcloud, Radicale, Fastmail. id: nextcloud_calendar name: Nextcloud Calendar connector: { kind: dav, base_url: https://cloud.example.com, service: caldav } auth: { kind: basic } identity: { kind: connector } setup: prompts: - { key: username, label: Username, scope: connection, field: username } - { key: password, label: App password, secret: true, scope: connection, field: password } ``` ```yaml # A vendor whose authentication is a protocol rather than a value. The strategy # is code and lives with the provider that owns it, so this names one that is # already registered rather than supplying it, which is also how you point a # connection at a vendor's sandbox. Copy the vendored spec in beside this file: # a relative `openapi` resolves against the manifest's own directory. id: bunq_sandbox name: bunq (sandbox) connector: kind: http base_url: https://public-api.sandbox.bunq.com/v1 openapi: ./bunq.v1.json auth: kind: strategy strategy: bunq ``` Then `lanes link connect mything`. The same schema validates a built-in, so the list in `src/providers/index.ts` is a convenience and never a boundary. ### When the credential is a handshake `auth: { kind: strategy }` is the escape hatch, and it is the **only** place per-vendor code is allowed outside a `local` provider ([ADR-008](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/008-connectors.md), [ADR-046](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/046-an-auth-strategy-belongs-to-its-provider.md)). Reach for it when a vendor wants something no field can describe. bunq generates a keypair, runs a three-step handshake, signs every request body, and signs its replies back. A strategy is three optional methods on the provider's definition: ```ts export const acme = defineProviderWithStrategy({ manifest, // auth: { kind: 'strategy', strategy: 'acme' } strategy: { id: 'acme', async setup(context) { /* once, at connect. The only place `context.write` exists. */ }, async authorize(request, context) { /* per request: sign it, add headers, renew a session */ }, async verify(response, context) { /* optional: check a signed reply, notice a 401 */ }, }, }); ``` Three things about it are not negotiable: **Keep it to auth.** It takes a request, not an operation. The moment a strategy branches on which endpoint is being called, the per-endpoint translation ADR-008 removed has come back. **`write` is setup-only.** A handshake persists what it produces; per-request code must not. The restriction is the absence of the key rather than a rule to remember, and it is not only a convention, because a deployed revision is granted write access on nothing for a non-OAuth provider. Anything a request needs to save goes in `context.state`, which is namespaced to the connection and shared across instances. **It lives in `src/providers/<id>/strategy/`, never under `connectivity/`.** That component is held free of vendor names by `src/architecture.test.ts`, and the seam there resolves a strategy without knowing which one it is. A YAML manifest may name any strategy a registered provider supplies, which is what the sandbox example above does. ### Where the credential goes A `scope: connection` prompt is the usual case: one secret per account, and the ref **derives**. You do not write it, because a manifest cannot name a connection that does not exist yet. Declare `credential_ref` instead when one secret genuinely serves every account of the provider (a service key, where the key *is* the identity). Declare `auth.app` when several providers of one vendor share a secret *per account*, that is how the three iCloud providers are asked for one app-specific password between them. The two contradict, so declaring both is refused. **A vendored spec beats a fetched one.** `openapi:` accepts a URL, but a spec decides which paths get called with your credential, and `connect` grants everything discovered, so prefer a local file you have read. The Google specs ship that way; `src/providers/google/specs/vendor.ts` shows the shape. --- ## The rest of this page: local providers Everything below is for a `local` provider: our own code, in-process. That is `example`, and it is what the owner layer is, meaning memory, tasks, assets, skills, vault, entities. **If you are integrating someone else's service, you almost certainly want a manifest above, not this.** ## Read this first **Provider code is trusted code.** It runs in-process with core and holds its connection's credential. There is **no provider sandbox**. Installing a third-party provider is equivalent to running arbitrary code with access to that account, and you should treat installing one exactly as seriously as that sounds. What a provider *is* prevented from doing is reaching **outside its own connection**: another connection's credentials, another provider's state, the profile token, the policy engine, or the audit log. Those boundaries are enforced in the wrapper, not by asking providers to behave. ## The shape ```ts import { z } from 'zod'; import { defineLocalProvider, keepKeys } from '@lanes-link/provider-sdk'; export const exampleProvider = defineLocalProvider({ id: 'example', // lowercase; forms the namespace `example.echo` name: 'Example', version: '1.0.0', description: 'A trivial provider with no external service.', configSchema: z.object({}), // provider-level settings connectionSchema: z.object({}), // per-connection settings auth: { kind: 'none' }, // the only kind a local connector may declare bundles: [ { name: 'read', description: 'Read notes.', oauthScopes: [], capabilities: ['echo', 'get_note'], default: true }, { name: 'write', description: 'Modify notes.', oauthScopes: [], capabilities: ['set_note'] }, ], capabilities: [ /* tools, resources, prompts */ ], }); ``` `defineLocalProvider` validates eagerly, so a malformed provider fails at import rather than at first invocation: duplicate capability names, a bundle referencing a capability that does not exist, and OAuth without setup steps are all caught there. `src/src/providers/example/provider.ts` is the full reference. It is deliberately small enough to read in one sitting. ## A tool ```ts { kind: 'tool', name: 'echo', // → `example.echo`, wire name `example_echo` title: 'Echo a message', description: 'Return the supplied message unchanged.', inputSchema: z.object({ message: z.string().min(1).describe('Text to echo back'), }), redact: keepKeys('message'), async handler({ message }, context) { return { content: [{ type: 'text', text: `[${context.connection.key}] ${message}` }] }; }, } ``` **Do not declare a `connection` argument.** Core injects it, populates its enum per profile from resolved policy, and resolves it to `context.connection` before your handler runs. That is [ADR-001](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/001-connection-routing.md), and keeping it out of provider code is what lets one tool set serve any number of accounts. **Return a tool error rather than throwing** when the failure is something the agent should read and react to, such as a missing record or a bad identifier. A thrown exception is contained and audited, but it reaches the agent as a failure rather than as information: ```ts return { content: [{ type: 'text', text: `No note "${key}".` }], isError: true }; ``` ## A resource Use a resource for read-oriented context addressed by a stable identifier; use a tool for actions and parameterised queries. Decide per capability and record the reasoning in the [capability reference](/docs/link/capabilities), [ADR-006](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/006-tools-resources-prompts.md). ```ts { kind: 'resource', name: 'note', description: 'A stored note, addressed by key.', uriTemplate: 'example://note/{key}', mimeType: 'text/plain', async list(context) { /* enumerate; omit when the space is unbounded */ }, async read(uri, params, context) { const value = await context.state.get(`note:${params['key']}`); if (value === null) throw new Error(`No note "${params['key']}"`); return { uri, mimeType: 'text/plain', text: value }; }, } ``` Use a **prompt** for a reusable procedure, something invoked, whose result *becomes* the conversation rather than data the model reasons about. The discriminator against a resource is whether the answer depends on arguments; a resource is a function of its URI alone ([ADR-012](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/012-owner-layer-primitives.md)). ```ts { kind: 'prompt', name: 'review-diff', description: 'Review a diff for correctness.', arguments: [{ name: 'diff', description: 'The unified diff', required: true }], async render(args, context) { return { messages: [{ role: 'user', text: `Review this diff:\n\n${args['diff']}` }] }; }, } ``` `skills` is the only provider using it so far, and it holds the primitive's one real caution: a prompt's messages enter the conversation as turns, so what a prompt renders shapes what the agent does next. Do not render text a caller supplied without deciding that is what you meant. **Routing is handled for you in all three cases.** A tool gets `profile` and `connection` injected as arguments; a resource gets them inserted into its URI, so your `read` receives only the variables you declared and your `list` may return your own unrouted URIs; a prompt gets them as optional arguments that default when there is one candidate. A provider never learns which account it is serving beyond `context.connection`. That is ADR-001, and it is why a `resource_link` you return is rewritten to a routed address before it reaches the client. ## What your handler is given ```ts interface ProviderContext { connection: ConnectionInfo; // id, key, displayName, validated config state: ScopedStore; // namespaced to <provider>/<connection> storage: BlobStore; // same namespace credentials: ScopedCredentials; // read-only, this connection's refs only audit: AuditLogger; // annotate this invocation; cannot read the log log: Logger; // prefixed with provider and connection signal: AbortSignal; } ``` Seven keys, and nothing else. No `Database`, no config, no policy engine, no registry. See [ADR-007](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/007-control-plane-exclusions.md), which has a test pinning that exact set. `state` and `storage` are already namespaced, so there is no key you can construct that reaches another provider or another connection. `credentials` is restricted to an allowlist computed from what your connection declares; asking for anything else throws, and an out-of-scope ref fails identically to a missing one. ## Redaction is your responsibility Every invocation is audited. What lands in the log is whatever your `redact` rule returns. **The default withholds every value**, recording argument names and value types only. Opt in deliberately: ```ts redact: keepKeys('key') // the key is useful and harmless; the value is the content ``` `redact` is available on every capability kind, not just tools: a resource read is dispatched with its URI as an argument, and an address is exactly as worth recording, or withholding, as a message id. For a secret, `keepKeys` is not enough: it reduces an unkept value to `<string:40>`, and a length is a real disclosure. Use `redaction` when you need both halves: ```ts redact: redaction({ keep: ['id'], withhold: ['value'] }) // '<withheld>', not '<string:40>' ``` A search query routinely contains the very content the caller may not be allowed to read. A message id is useful and harmless. Think about which yours is, and note that the rule is applied to **denials too**, so a refused call cannot leak through the log what an allowed one would have hidden. `context.audit.annotate({ bytes: value.length })` adds provider-specific detail. Whatever you pass is persisted verbatim, so redact it yourself. ## Declaring credentials ```ts credentialRefs(connectionId, config) { return [`gmail/${connectionId}`, 'google/client_secret']; } ``` Core turns this into the allowlist behind `context.credentials`. If you declare nothing, the connection's own `credential_ref` is the entire allowlist. ## Setup steps For anything needing credentials, declare how an operator obtains them. The CLI renders whatever you declare, which is how `lanes link connect <provider>` stays one command and how core avoids ever learning what your provider is: ```ts setup: { summary: 'Gmail needs a Google Cloud OAuth client that you register yourself.', docsUrl: 'https://console.cloud.google.com/apis/credentials', steps: [ 'Create a project at console.cloud.google.com', 'Create an OAuth client of type "Desktop app"', 'Move the app out of "Testing" publishing status', ], prompts: [ { key: 'client_id', label: 'Client ID', secret: false, credentialRef: 'google/client_id' }, { key: 'client_secret', label: 'Client secret', secret: true, credentialRef: 'google/client_secret' }, ], } ``` Only the `_ref`s reach the config file; values go to the credential store. ## Bundles A bundle maps one name onto both layers that "permission" means: ```ts { name: 'read', oauthScopes: ['gmail.readonly'], capabilities: ['search', 'get_message'], default: true } ``` These behave differently, and the difference is not cosmetic: **`oauthScopes` are what the vendor grants us, and widening them requires browser re-consent**, while `capabilities` are what an agent may invoke and are purely local. Tightening is free; widening needs consent. `lanes link connect` requests the default bundle and writes the matching allow lines into the config, showing you the diff. That does not weaken default deny: the runtime is still deny-unless-listed; the file is pre-populated rather than hand-typed. ## Registering it Built-in providers are statically imported in `src/cli/runtime.ts`. Nothing in the registry assumes that, so independently versioned packages remain possible. The ids `memory`, `tasks`, `assets`, `skills`, `vault`, `setup` and `identity` are **reserved** for the owner layer and are still refused at registration. The guard was never about the layer being unbuilt, and reclaiming a namespace once providers exist in the wild would silently change what a policy rule means. Only the built-in registry opts in. The guard bites in the other direction too, which is worth knowing before picking a name. `tasks` was Google Tasks until the built-in list took the plain noun, and it could not simply be added: `buildRegistry` registers the owner layer before looping over `PROVIDERS`, so the manifest holding that id threw on the second registration rather than being shadowed. The vendor surface is now `google_tasks` ([ADR-051](https://github.com/lanes-sh/link/blob/main/docs/detailed/adr/051-tasks-and-assets-are-their-own-stores.md)). ## Testing No server, no policy layer, no transport: build a context, call a handler, assert on the result. `src/providers/example/src/index.test.ts` doubles as the worked example, and it covers the parts worth covering: that a missing record is a tool error rather than a throw, that state is per connection, that redaction withholds what it should, and that no capability declares a `connection` argument. Source: https://lanes.sh/docs/link/creating-a-provider --- # Local development > Working on Lanes Link itself ## Requirements **Bun 1.3.11+** (pinned in `.bun-version`). Nothing else. There is no build step; Bun runs TypeScript directly. ```console $ bun install $ bun test $ bun run typecheck ``` ## Running the CLI from source ```console $ bun run lanes link <command> # via the root script $ bun run src/cli/lanes.ts link … # directly $ bun link # or put this checkout's `lanes` on your PATH ``` `bun link` is what people who installed `@lanes-sh/link` from npm get, pointed at your checkout instead: it reads the same `bin` entry, so `lanes` runs `bin/lanes`, which runs this tree. Prefer it over the two lines above once you are running more than one command at a time, and note that it replaces any published `lanes` on your `PATH` until you `bun unlink`. Use a throwaway workspace so you never touch a real profile: ```console $ export LANES_LINK_HOME=/tmp/lanes-link-dev $ bun run lanes link profile add personal --workspace local $ bun run lanes link connect example $ bun run lanes link start ``` ## Pointing the OAuth broker somewhere else A provider whose manifest declares `auth.broker`, which every Google REST provider does, authorises against a client somebody else operates, and the exchange happens at that operator's origin. For production that is the right answer and there is nothing to configure. For working *on* the broker it is not: you want the one on your machine, or the one on stage. `LANES_LINK_BROKER_ORIGIN` replaces the origin and leaves the path alone, for every provider at once: ```console $ export LANES_LINK_BROKER_ORIGIN=http://127.0.0.1:8080 $ bun run lanes link connect gmail warn LANES_LINK_BROKER_ORIGIN is set — the authorization code will be exchanged at http://127.0.0.1:8080, not by Lanes. ``` It reaches both callers, because both read the same manifest field: the CLI performing the first exchange, and the endpoint refreshing while it serves. Three things it deliberately will not do: - **Fall back when the value is malformed.** It throws. A variable that is ignored when wrong is how you send a real authorization code to production while believing you are testing locally. - **Accept `http` for anything but loopback.** Off this machine that puts an authorization code on the wire in the clear. Use `https` for a remote broker. - **Keep a path.** `https://stage.example.com/v9/nope` becomes `https://stage.example.com`. The provider owns its path; two places deciding where `/exchange` lives would disagree eventually. The warning above is the point of the feature as much as the redirect is: an override left exported in a shell is otherwise invisible. ## Opening a different Lanes build `lanes link desktop` opens `lanes://settings?page=integrations-link`, and macOS routes a scheme to exactly one bundle. The released app registers `lanes`; a local debug build of the app registers `lanes-dev` and Lanes Stage registers `lanes-stage`, so testing against either means naming it: ```console $ LANES_LINK_APP_SCHEME=lanes-dev bun run lanes link desktop ``` A debug build has to be a real `.app` before LaunchServices will route to it, because the raw binary a `bun run app` produces has no `Info.plist` and never sees a URL click. The app repository's `docs/development/deep-links.md` has the `tauri build --debug --bundles app` and `lsregister` steps. `--print` needs none of that, and is the cheaper check when what you are testing is this side: ```console $ bun run lanes link desktop --print lanes://settings?page=integrations-link ``` ## Layout Dependencies run one way: infrastructure interfaces → provider SDK → core → mcp → server/cli. Nothing above imports a backend directly. See [`architecture.md`](/docs/link/architecture). Bun installs with the **isolated linker**, so each package sees exactly the dependencies it declares. If an import resolves in your editor but fails at runtime, the package is missing a dependency entry rather than the import being wrong. ## Bun-specific code Confined on purpose: - `src/server/index.ts` (`Bun.serve`) - `src/deployments/adapters/s3.ts` (`Bun.S3Client`) Everything else is portable TypeScript. Keep it that way. `bun:sqlite` used to head this list and no longer appears anywhere: state and the log are objects in a `BlobStore`, so the local workspace opens no database at all. ## Adding a dependency ```console $ bun add <package> ``` `bunfig.toml` sets `minimumReleaseAge = 604800`, so bun resolves to the newest version at least seven days old rather than to `latest`. That is intentional: the common npm attack publishes a compromised version and yanks it within hours. If you need a newer one for a security fix, install the exact version explicitly and say why in the commit message. Before adding anything, consider whether it is worth it. This repository holds live refresh tokens, and the runtime dependency set is deliberately small. ```console $ bun pm scan # lockfile CVEs ``` ## Testing `bun:test`. Tests live beside the code as `*.test.ts`. Test-only helpers are behind separate entry points so application code cannot reach them by accident: ```ts import { createMemoryState, createMemoryCredentials } from '#stores/state/testing.ts'; import { createMemoryBlobStore } from '#stores/blobs/testing.ts'; ``` Server tests run a **real HTTP server on a real port** rather than mocking the transport, because what is worth proving is what an agent actually sees. `src/server/harness.ts` starts a fully wired profile and speaks the 2026-07-28 wire format; note that the revision requires `Mcp-Method` (and `Mcp-Name`) headers matching the body, and rejects requests where the two disagree. Write the test that would have caught the bug. Several real defects in this codebase were found by tests written before the fix, a scoped store throwing synchronously from an async interface, workspace discovery treating every path as existing, and an unadvertised-tool attempt leaving no audit trace. Source: https://lanes.sh/docs/link/local-development --- # The development lifecycle > The development lifecycle: branches, the two release paths, and what to verify How a change gets from a branch to a version somebody installs. [`local-development.md`](/docs/link/local-development) covers working *in* the tree; this page covers the branches around it and the one workflow that publishes. The whole of it is four moves: ``` branch → PR → develop → PR → main → npm ``` `main` is what is published. `develop` is where reviewed work accumulates between releases. They are meant to be **identical trees** once a release lands, and the last step of a release is what makes that true, and it is the step most easily forgotten. ## The branches | | | |---|---| | `main` | What `npm install @lanes-sh/link` gets. A push here is what publishes. | | `develop` | The integration branch, and this repository's **default** branch. | | feature branches | One per change, in its own worktree. See [CLAUDE.md](https://github.com/lanes-sh/link/blob/main/CLAUDE.md). | `main` and `develop` carry the same ruleset: pull request with one approval, code-owner review, resolved review threads, a passing `ci`, no force-push, no deletion. The only bypass is an organisation admin, which is how a solo release merges its own pull request. **The ruleset names both branches literally, and that is deliberate.** It used to name `~DEFAULT_BRANCH`, one rule for "whichever branch is default". When the default was changed to `develop`, every rule moved with it and `main` was left with none at all, while the ruleset was still called "main" and `CONTRIBUTING.md` still said `main` required a pull request. For a branch whose pushes publish to npm, that gap is worth more than the convenience of a symbolic name. Because both branches require a pull request, the two direct pushes a release performs, the version bump onto `develop` and the fast-forward at the end, land only under that admin bypass. Without it, both are pull requests, and the fast-forward becomes an ordinary merge of `main` into `develop` that leaves one commit between them. Nothing is ever pushed to `main` by CI. It cannot be: the ruleset requires a pull request there and the built-in `GITHUB_TOKEN` cannot be added to its bypass list. Doing it anyway would mean storing an App key or a personal access token with write access to `main`, a larger credential than the npm token this project deliberately does not have. ## How a pull request is merged **Squash, with one exception.** Every pull request into `develop` is squashed: one change becomes one commit, and the commit message is the one written for the change rather than a merge line naming a branch nobody can see any more. `gh pr merge <n> --squash --delete-branch`. **The release pull request, `develop` → `main`, is a merge commit.** Not a preference: squashing it makes the closing fast-forward impossible. A squash writes a *new* commit onto `main` whose parent is `main`'s old tip, so `develop`'s tip stops being an ancestor of `main`. The trees are then identical and the branches have still diverged: ```console $ git merge --squash develop && git commit -m "squashed" $ git rev-parse main^{tree} develop^{tree} # the same tree $ git merge-base --is-ancestor develop main # false $ git push origin origin/main:refs/heads/develop ! [rejected] (non-fast-forward) ``` The only way out of that is a force-push of `develop`, which the ruleset blocks and which would rewrite reviewed history to fix a cosmetic choice. A merge commit keeps `develop`'s tip an ancestor, which is exactly what the fast-forward needs. `gh pr merge <n> --admin --merge`. A proposed `release/next` pull request may be squashed, because it holds one commit, and `develop`'s tip is already an ancestor of `main` by then, so nothing depends on how that one lands. **Rebase-merging is disabled**, in the repository settings and in the ruleset's `allowed_merge_methods`. It rewrites the commits onto `main` under new hashes, which breaks the ancestry the same way a squash does while looking like it preserved history. | Pull request | Method | |---|---| | anything → `develop` | squash | | `develop` → `main` (the release) | merge commit | | `release/next` → `main` | squash or merge commit | | any pull request | never rebase, the method is disabled | ## Ordinary work ```console $ git worktree add .worktrees/<name> -b <name> $ cd .worktrees/<name> $ bun install $ bun test && bun run typecheck # the baseline, before changing anything ``` Then the change, then the same two commands, then a pull request into `develop`. `ci` runs both gates again where a reviewer can see them. Squash it when it is green: ```console $ gh pr merge <n> --squash --delete-branch ``` That is the whole of ordinary work. A release is a separate act, taken deliberately, described below. ## What a release is One workflow, [`.github/workflows/release.yml`](https://github.com/lanes-sh/link/blob/main/.github/workflows/release.yml), fires on a push to `main` and does exactly one of two things. Which one is decided by a single question: **does the version in `package.json` already have a tag?** | `package.json` | Meaning | What the run does | |---|---|---| | tagged (`v0.4.0` exists) | already published | **propose**, which pushes a `release/next` branch carrying a patch bump, and links the pull request that opens it | | untagged | somebody set it deliberately | **publish**, which runs both gates, `npm publish --provenance`, then cuts the tag and the GitHub release | So the same file both proposes a release and performs one, and never both. You do not tag anything by hand, and there is no stored npm credential: publishing authenticates over OIDC as this repository. **The filename is load-bearing.** npm's trusted publisher for this package names `release.yml`. Renaming the file makes every publish fail to authenticate, with an error about OIDC rather than about the rename. It also only fires for a merge that can change the tarball. The `paths` filter is `src/**`, `bin/**`, `instructions/**`, `package.json`, `bun.lock`, the same list as `files` in `package.json`, and the two should stay in step. A docs-only or CI-only merge to `main` starts no release run at all, which is why a typo fix does not spend a version number. ## Cutting a patch Merge the work to `main` and let the workflow propose it: 1. PR `develop` → `main`, merge it. 2. The release run takes the **propose** path and pushes `release/next` with the patch bump. Its commit message is the release note, so GitHub pre-fills the pull request's title and body from it, so opening the release is one click. The run summary links straight there. 3. Merge that pull request. That push takes the **publish** path. 4. Fast-forward `develop`, below. The workflow does not open the pull request for you on purpose: that needs "Allow GitHub Actions to create and approve pull requests", and the approving half of that permission would let a workflow satisfy the review this repository requires. ## Cutting a minor or a major The judgement that something earns more than a patch is made in review, beside the change that earns it, so you set the version yourself, and you set it **on `develop`, before the merge to `main`**. ```console $ git switch develop && git pull --ff-only $ bun install --frozen-lockfile $ bun test && bun run typecheck # green before the bump, or there is nothing to release ``` Edit the one line in `package.json`. No other file in the repository names a version; the README badge reads npm live. Then commit it with the release note as the message: ```console $ git commit -m "Release 0.4.0" -m "$(cat <<'NOTE' Merging this to main publishes 0.4.0 to npm. Since v0.3.2: - <one line per change, from git log --no-merges --pretty=format:'- %s'> Why this earns a minor rather than the workflow's patch bump. The version is set here, on develop, so the push to main arrives untagged and publishes directly. NOTE )" $ git push origin develop ``` Two `-m` arguments, not one: passing the whole note as a single string runs the subject and the first body line together into one 60-character subject, which is what happened to `Release 0.3.1`. Then PR `develop` → `main`, wait for `ci`, and merge it **as a merge commit**: `gh pr merge <n> --admin --merge`, the `--admin` because `main` requires an approval and the `--merge` because a squash here would strand `develop` (see [above](#how-a-pull-request-is-merged)). **That merge is the irreversible step**: it publishes, and the registry does not give a version back. ### Why the version goes on `develop` and not on a release branch Because of what the alternative leaves behind. Merge `develop` to `main` with a tagged version and the propose path runs: the bump lands on `release/next`, which merges into `main` only. `develop` is then behind by a version commit forever, and every later comparison between the two branches is noise. Setting it on `develop` first means the push to `main` arrives untagged, publishes directly, and both branches hold the same tree. ## After the release: fast-forward `develop` `main` now carries the merge commit `develop` lacks. The trees are already identical, so this is a fast-forward and not a merge: ```console $ git fetch origin $ git push origin origin/main:refs/heads/develop ``` Verify it, both directions, because "0 ahead" alone does not prove they match: ```console $ git rev-list --count origin/main..origin/develop # 0 $ git rev-list --count origin/develop..origin/main # 0 ``` **A release is not finished until both of those read `0`.** The workflow is designed around `main` alone and says nothing about `develop`, so this is the operator's step to remember, and the reason the pull request above is a merge commit rather than a squash. If the second count is not `0`, something published from `main` that `develop` never saw, usually the propose path's patch bump. Fast-forward again. If the *first* is not `0`, `develop` has moved on since the release, which is ordinary. ## Verifying a release actually shipped ```console $ gh run list --workflow release.yml --limit 1 # conclusion: success $ npm view @lanes-sh/link version # the version you set $ gh release view v<version> --json tagName,url ``` Read the run's step list rather than the tail of `gh run watch`: that tail prints annotations, and an annotation from an *earlier* run appears there looking like a failure in this one. A run whose steps are all `success` succeeded. Two things will look wrong and are not: - **The release pull request shows `ci` as never reporting.** GitHub does not run workflows on branches it pushed itself, so a proposed release needs an admin merge. Both gates run again inside `release.yml` against the exact tree being published, which is the run that matters. - **`push the next release` is skipped in the run.** That is the propose step, correctly skipped on a publish run. It is how you confirm the publish path was taken. ## If a release goes wrong - **Publish failed, nothing tagged.** Publish runs *before* the tag for this reason: a failure leaves the repository untouched and the run repeatable. Fix and re-run. Tagging first would leave a tag claiming a version nobody can install, which the next run reads as released and skips forever after. - **Published a version you did not mean to.** It cannot be reclaimed. Set the next version deliberately and release again. - **`develop` is behind `main` by a version commit.** The propose path ran when a deliberate version was wanted. Fast-forward, as above. Source: https://lanes.sh/docs/link/releasing # Use cases # Manage multiple Claude Code sessions > Run several Claude Code sessions at once, each on its own branch, tracked on one board instead of a wall of terminal tabs. Running one Claude Code session is easy. Running five is where it falls apart: terminal tabs you cannot tell apart, agents waiting on input you never noticed, and branches quietly stepping on each other. Lanes is built for exactly this. It runs the official `claude` CLI in a real terminal, one session per task, and puts them all on a single board so you can see what every agent is doing at a glance. ## How Lanes does it - **One session per issue.** Each task on the board gets its own Claude Code session in its own terminal. No shared state, no guessing which tab is which. A [session](/docs/desktop/sessions) is a real PTY attached to the issue, so the task, its output, and its results live in one place. Close the detail panel and the session keeps running in the background; click the card to reconnect. - **Live status on every card.** See which agents are working, which are waiting for input, and which have stopped. A bell rings when one needs you. The full set of states is Busy, Awaiting input, Stopped, Exited, and Error, and when an issue runs several sessions the card aggregates them so the most active state wins. - **Isolated by default.** Every session runs in its own git worktree on its own branch, so several agents can work the same repo without colliding. Lanes creates [worktrees](/docs/desktop/worktrees) under `.worktrees/` in your project automatically and removes them once the issue completes and the worktree is clean. - **Your subscription, not a meter.** Sessions run the real CLI, so they draw from the Claude plan you already pay for. No SDK wrapper, no separate meter; [keep your Claude subscription](/use-cases/keep-your-claude-subscription) has the full billing picture. Sessions survive more than a panel close. Stop and restart a Claude Code session and Lanes passes `--resume`, so the conversation history from the previous run carries over. Since v0.41 one issue can also host several named sessions that share the issue's worktree while each keeps its own transcript, status, and metrics. A common split is one session to plan, one to implement, one to review. Each card opens into a detail panel with the terminal on the left and the Issue and Meta tabs on the right, so instructions, tokens, cost, and runtime sit next to the live output. The terminal keeps roughly 50KB of scrollback, and dropping a file onto it injects the path when you want to point the agent at something specific. When something goes wrong, the [process manager](/docs/desktop/process-manager) shows every CLI process Lanes knows about: tracked sessions, orphans left over after a crash, and external `claude` processes it did not start. Stop Sessions winds down tracked sessions gracefully; Kill All clears the slate. ## Try it Create a few issues, start a Claude Code session on each, and watch them run side by side. Move a card to Done and Lanes cleans up its worktree for you. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Add a project.** Point Lanes at the folder where your repos live. 2. **Create issues with Cmd+N.** The instructions you write become the session's initial prompt. 3. **Start each one with Plan or Implement.** Plan proposes an approach before any edits; Implement starts changing code right away. 4. **Answer the bell.** Click a card when its agent is awaiting input, type in the terminal, move to the next card. The [quick start](/docs/desktop/quick-start) covers the whole first run in about two minutes. Five bare terminals give you no signal about which agent stalled twenty minutes ago; the tab that needs input looks exactly like the one that is busy. One card per session, live status, and the bell are what make five Claude Code sessions manageable from one window. ## Further reading - [Working with sessions](/docs/desktop/sessions): lifecycle, status states, resume behavior, and several sessions on one issue. - [Worktree management](/docs/desktop/worktrees): isolation, dirty-state warnings, and auto-cleanup. - [Process manager](/docs/desktop/process-manager): tracked sessions, orphans, and external agents in one dialog. - [Run AI coding agents in parallel](/use-cases/parallel-ai-coding): the same board applied to any agent CLI. [Get Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/use-cases/manage-multiple-claude-code-sessions --- # Run AI coding agents in parallel > Give each task its own agent and run them at the same time, on isolated branches, from one board. Serial AI coding wastes the biggest advantage agents have: they can work while you do something else. The blocker is usually tooling, not the model. Running several agents by hand means juggling terminals and untangling branches, so most people give up and run one at a time. Lanes makes parallel the default. It is a desktop workspace built around an [issue board](/docs/desktop/issue-board): you describe tasks as issues, start a real agent session on each, and every session runs on its own git branch so the work never collides. The board shows all of it at once. ## How Lanes does it - **One agent per issue.** Each task gets its own terminal running the official CLI. A [session](/docs/desktop/sessions) is a real PTY attached to the issue, so the task, its output, and its results live in one place. Close the panel and it keeps running; click the card to reconnect. - **Isolated by default.** When an issue starts, Lanes creates a git [worktree](/docs/desktop/worktrees) and branch for it under `.worktrees/` in your project. Three agents on three issues means three working directories on three branches: no collisions, no merge conflicts mid-work. - **One board.** Every session's state is visible at once: busy, awaiting input, stopped, exited, or error. A bell rings when an agent has a question, so you answer when needed instead of babysitting terminals. See [the issue board](/docs/desktop/issue-board). - **Any agent.** Sessions run the official CLIs, and you pick the CLI, model, and reasoning effort per session at launch. Switch a session between Claude Code and Codex whenever you want. ## The workflow Break work into issues, start a session on each, and let them run. Each issue carries its own instructions, and those instructions become the agent's initial prompt. Start in Plan mode when you want to see an approach before any edits, or Implement mode to let the agent start changing code immediately. The issue moves across the board (Planning, Implementation, Review, Done) as the work progresses. While agents run, you stay in one window. Click a card to attach to its terminal, type to answer a question, then move on to the next card. When an agent finishes, review its changes with the inline diff, run your tests inside that issue's worktree, and merge. Completing the issue stops its sessions and removes the worktree automatically if it is clean; if anything is uncommitted, Lanes warns you first. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Add a project.** Point Lanes at the folder where your repos live. 2. **Create issues.** Press Cmd+N, give each task a title and instructions. 3. **Keep the worktree strategy on Create** (the default for git repos) so each issue gets its own branch. 4. **Select an issue and press Plan or Implement.** Repeat for as many tasks as you have. The [quick start](/docs/desktop/quick-start) walks through the whole first run in about two minutes. Doing this by hand means one terminal tab per agent, one hand-made worktree per branch, and no view of who is blocked. It works for two tasks and falls apart at five. The board, the bell, and automatic worktree cleanup are what make a fleet manageable. ## Further reading - [Working with sessions](/docs/desktop/sessions): the session lifecycle, status indicators, and running several sessions on one issue. - [Worktree management](/docs/desktop/worktrees): strategies, auto-creation, dirty-state warnings, and cleanup. - [The issue board](/docs/desktop/issue-board): columns, bulk actions, and board tabs. - [Manage multiple Claude Code sessions](/use-cases/manage-multiple-claude-code-sessions): the same workflow, focused on Claude Code. [Get Lanes Desktop](/desktop) or read about [sessions](/docs/desktop/sessions). Source: https://lanes.sh/use-cases/parallel-ai-coding --- # Git worktrees for AI agents > Why every agent should get its own git worktree, and how Lanes creates and cleans them up for you. When two agents edit the same working tree, they clobber each other. Git worktrees solve this: each is a separate checkout of the same repo on its own branch, sharing history without sharing files. Doing this by hand, running `git worktree add`, naming branches, and cleaning up after, gets old fast. Lanes does it for you. ## How Lanes does it - **Auto-created.** A worktree with a generated branch name appears when a session starts. Lanes puts it under `.worktrees/` in your project root, names it `{issueId}-{adjective}-{noun}` (say `407-fond-cave`), and branches it off the per-project base branch. The other [worktree strategies](/docs/desktop/worktrees) cover the remaining cases: None runs the session in the project directory, Select reuses an existing worktree. - **Tracked live.** Uncommitted and unmerged state is shown per worktree in real time. The status popover in the status bar lists every active worktree grouped by project, with dirty indicators for uncommitted changes and unpushed commits. - **Auto-cleaned.** The worktree is removed when the issue reaches Done. If it is dirty, Lanes warns you first and explains what uncommitted state exists, so you never silently lose work. - **No collisions.** Each agent has its own files, its own branch, its own terminal. Three [sessions](/docs/desktop/sessions) on three issues means three directories on three branches. Reviewing the work happens in the same panel. The built-in [git viewer](/docs/desktop/git-integration) compares the worktree against the base branch in two modes: Changes shows everything the branch has done so far, History walks the commit log one commit at a time. Diffs open in a Monaco side-by-side view, the same diff editor VS Code uses. Because each worktree is an independent directory, you can run a dev server or a test suite inside one without touching the others. [Quick commands](/docs/desktop/quick-commands) execute with the worktree as their working directory, and two built-in ones handle the finish line: Test Worktree runs your test suite inside the worktree, and Complete & Merge commits what remains, merges the branch into the base branch, and completes the issue. When many worktrees are active at once, per-worktree board tabs filter the board down to the issues running in a single one. ## Why it matters Worktrees are what make real parallelism safe. A handful of agents can touch the same repo at once and none of them steps on another. You get the throughput of many agents without the merge chaos. Worktrees themselves are not new; `git worktree add` has been in git for years. What stops people is the lifecycle. Creating, naming, branching, watching for dirty state, and removing worktrees is bookkeeping, and bookkeeping is exactly what makes running [agents in parallel](/use-cases/parallel-ai-coding) feel harder than it is. ## Set it up 1. **Add a project.** Lanes detects the base branch (usually `main`), and you can override it in Settings. 2. **Leave the worktree strategy on Create.** It is the default for git repos; every new issue gets its own branch and directory. 3. **Start sessions.** Each one lands in its own worktree automatically. 4. **Finish with Test Worktree, then Complete & Merge.** Tests run inside the worktree; the merge brings the branch back to base and cleans up. Add `.worktrees/` to your `.gitignore` if you do not want the directory tracked. ## Where this breaks without Lanes Hand-rolled worktrees fail at the edges: a branch named after nothing, a directory you forgot to remove, an agent quietly running in the checkout you were editing. One stale worktree with uncommitted changes is enough to make you distrust the whole setup. Lanes owns the full lifecycle, from creation to warned-before-removal, so the edges are where it is most careful. ## Further reading - [Worktree management](/docs/desktop/worktrees): strategies, auto-creation, dirty-state warnings, and cleanup. - [Git integration](/docs/desktop/git-integration): Changes and History modes and the Monaco diff view. - [Quick commands](/docs/desktop/quick-commands): per-repo commands that run inside the issue's worktree. - [Run AI coding agents in parallel](/use-cases/parallel-ai-coding): the workflow worktrees make safe. [See worktrees in the docs](/docs/desktop/worktrees) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/git-worktrees-for-ai-agents --- # Run Claude Code and Codex side by side > Use the right agent for each task, or race them on the same one, without leaving your workspace. Claude Code and Codex are both strong, and they are strong at different things. Lanes lets you run both, so you pick per task instead of committing to one tool for everything. ## How Lanes does it - **Either agent, per session.** Start a session as Claude Code or Codex, and switch a session's CLI anytime. The new-session picker opens a [Session Settings](/docs/desktop/sessions) flyout with CLI, Model, Effort, and Flags in one place, and a pill shows the current selection at a glance, `Claude Code / claude-opus-4-7 / max` for example. - **Real CLIs.** Each runs as its official command in a real terminal, on your existing subscription for that provider. A session is a real PTY, so Claude Code behaves exactly as it does in your own terminal, and so does Codex. Gemini CLI is available too, and Cmd+T opens a bare terminal when you want a plain shell. - **Run them together.** Put them on different issues in parallel, or as sibling sessions on the same issue, and compare the results. The model picker lists whatever models the selected CLI supports, and Effort maps to the reasoning budget where the CLI exposes one. Repo-wide defaults come from [Settings](/docs/desktop/settings): a default CLI under General and persistent flags under CLI Flags, pre-filled into every new session and overridable per session. Lanes captures what each session actually launched with, so you can check the side panel later instead of guessing which model wrote which diff. For a direct comparison, spawn a sibling session on the same issue and give it a different CLI or model; each sibling keeps its own transcript, status, and metrics, so you review both approaches and keep the diff you prefer. For full isolation, create two issues for the same task and let each get its own [worktree](/docs/desktop/worktrees) and branch, then compare the branches. ## Why it matters You are not locked into one vendor's agent. Claude Code stays Claude Code, Codex stays Codex, and Lanes is the workspace around both. When a new CLI agent matters to your work, you adopt it without changing how you work. The comparisons in [Lanes with Claude Code](/compare/lanes-with-claude-code) and [Lanes with Codex](/compare/lanes-with-codex) go deeper on what Lanes adds around each CLI. The short version is that it adds the same things to both, which is exactly why switching between them stays cheap. ## Set it up 1. **Install both CLIs** and log in to each. Lanes launches the official command with your existing auth, whichever you pick. 2. **Set a repo default** under Settings, then General, so most sessions start with the CLI you use most. 3. **Override per session.** Open the Session Settings flyout when starting a session and flip the CLI, model, or effort for that run only. 4. **Race them when it matters.** Same instructions, two sessions, two diffs; merge the one you prefer. ## Where this breaks without Lanes Running two vendors' agents by hand means two terminal setups, two sets of flags in your shell history, and no record of which model produced which change. The moment you want a fair comparison on one task, you are hand-building a branch for each. One picker, per-session settings capture, and a worktree per issue make dual-CLI work routine. ## Further reading - [Working with sessions](/docs/desktop/sessions): the Session Settings flyout and sibling sessions. - [Settings](/docs/desktop/settings): repo-default CLI, flags, and environment variables. - [Worktree management](/docs/desktop/worktrees): isolation when both agents touch the same repo. - [Lanes with Claude Code](/compare/lanes-with-claude-code): what Lanes adds around the `claude` CLI. - [Lanes with Codex](/compare/lanes-with-codex): the same workspace around the Codex CLI. [Get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/claude-code-and-codex-side-by-side --- # Multi-agent coding orchestration > Plan work, dispatch agents, and let one agent spin up more, all from a single board with Lanes Desktop MCP. Orchestration is more than starting sessions. It is deciding what runs, in what order, and wiring the results back. Lanes gives you a board to orchestrate by hand and Lanes Desktop MCP to orchestrate from inside an agent. ## How Lanes does it - **A board, not a queue.** Plan work as issues, start a session per issue, and drag cards through plan, implement, review, and done. The [issue board](/use-cases/issue-board-for-ai-agents) is the shared state every agent and every human can see. - **Agents that dispatch agents.** The Lanes MCP server lets an agent manage the board and spawn sessions itself. Tell one agent to dispatch three more. The [Lanes Desktop MCP server](/docs/desktop/local-mcp) is built into the app, runs as SSE on `localhost:5353` under the name `lanes-desktop`, and exposes 30 tools: 18 for the workspace plus 6 each for GitHub and Linear once those are connected. - **Quick commands.** Inject preset prompts into any session in one click. [Quick commands](/docs/desktop/quick-commands) are per-repo, typed as either Claude prompts or raw terminal commands, bound to Cmd+Alt+1 through 9, and they run inside the issue's worktree. - **Safe at scale.** Every session is isolated in its own worktree, so orchestrating many at once never corrupts your repo. Through MCP, an agent can list and search issues, create and move them, start a [session](/docs/desktop/sessions) in plan mode or with a custom prompt, tie it to a freshly created worktree, then read its terminal scrollback and session stats to answer "how is this issue going" on its own. Ordering comes from [dependencies](/docs/desktop/dependencies): the graph is a DAG with cycle detection, and the queue manager skips blocked issues, so a chain of issues in the backlog executes in the right order without a human sequencing it. Sessions support an orchestration pattern of their own. Since v0.41 one issue can host several named sessions sharing the same worktree: one plans, one implements, one reviews, each with its own transcript and metrics. ## Why it matters What used to take a workflow now takes a sentence. You keep oversight of the whole fleet while the agents handle the wiring. The chains are the interesting part: an agent that reads the backlog, picks the next ticket by label, starts a session in a worktree, and reports back when it gets blocked. Every step of that is an MCP tool call against the board you are already looking at. ## Set it up 1. **Enable the server.** In Lanes, open Settings, then Local MCP, and toggle it on. The server ships as a research preview. 2. **Connect your agent.** One-click buttons write the `lanes-desktop` entry for Claude Code or Codex; any other MCP client can point at the SSE endpoint on port 5353 manually. 3. **Restart the agent** and ask it something like "what is on my plate in Lanes" to confirm the tools are live. 4. **Give it work.** "Create issues for these three refactors, add dependencies, and start the first one in plan mode" is a valid instruction. The local server is one of three Lanes MCP servers. [The MCP overview](/docs/mcp) explains how `lanes-desktop`, the hosted `lanes` server, and the self-hosted `lanes-link` endpoint differ and why they never collide. ## Where this breaks without Lanes Multi-agent setups without a shared workspace tend to become scripts: one process spawns others, state lives in log files, and nothing shows you what is running or why. When a step fails you reconstruct the plan from stdout. With the board as shared state, dispatch stays inspectable: every agent-created issue, session, and status change is a card you can click. ## Further reading - [Lanes Desktop MCP](/docs/desktop/local-mcp): the tool surface, connect flow, and example prompts. - [MCP overview](/docs/mcp): the local and remote servers and when to use each. - [Dependencies](/docs/desktop/dependencies): DAG ordering and the queue manager. - [Working with sessions](/docs/desktop/sessions): several named sessions on one issue. - [An issue board for AI agents](/use-cases/issue-board-for-ai-agents): the board that orchestration runs on. [Read about Lanes Desktop MCP](/docs/desktop/local-mcp) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/multi-agent-coding-orchestration --- # An issue board for AI agents > Plan, run, review, and ship agent work as cards moving through a board built for the full coding loop. Most AI coding tools drop you into a chat. That is fine for one task, but it does not scale to a queue of work. Lanes uses an issue board instead, so agent work has structure: a backlog, work in progress, and things waiting on review. ## How Lanes does it - **Columns for the full loop.** Plan, implement, review, done. The [board](/docs/desktop/issue-board) runs six columns in total: Backlog, Planning, Implementation, Review, Done, and Misc as a catch-all. Agents started in plan mode land in Planning; agents changing code live in Implementation. - **Live sessions on cards.** Each card can run an agent with its own worktree and terminal. Click a card and the detail panel opens with the terminal on the left and the issue's notes, instructions, and metrics on the right. - **Status at a glance.** Busy, awaiting input, stopped, or error on every card, with a bell when one needs you. - **Labels and filters.** Slice a large board down to what matters right now. [Labels](/docs/desktop/labels-and-filtering) are per-repository and color-coded, they combine with directory and workflow-step filters, and a filter badge reminds you the board is not showing everything. Issues are cheap to create. Press Cmd+N, give the task a title and instructions, and the instructions become the agent's initial prompt when a session starts. [Deep links](/docs/desktop/deep-links) go one step further: a `lanes://new` URL from Linear, GitHub, a script, or a bookmarklet opens Lanes with a draft issue already filled in. Order is explicit too. [Dependencies](/docs/desktop/dependencies) link issues that block each other, and cycle detection keeps the graph a DAG. Blocked issues carry a badge on the board, and the queue manager skips them when it auto-fills session slots from the backlog, so chained work runs in the right order without you sequencing it by hand. At scale the board has the bulk tools you would expect: multi-select with Shift+Click or Cmd+Click, a bulk toolbar for stop, remove, and complete, board tabs for all issues, per-project, and per-worktree views, and sorting by manual order, newest, updated, or title. ## Why it matters A board gives you oversight. You see the whole pipeline instead of one chat window, and you decide what each agent picks up next. Planning, running, and reviewing live in the same place. It also gives agent work a memory. A chat transcript scrolls away; a card keeps the task, the instructions, the session output, and the cost in one place you can come back to tomorrow. ## Set it up 1. **Add a project** and press Cmd+N to create your first issues. A title plus instructions is enough. 2. **Label as you go.** Right-click a card, choose Add Label, and build the taxonomy inline. 3. **Wire dependencies** for work that must land in order: right-click, Add Dependency, pick the prerequisites. 4. **Start sessions** with Plan or Implement, and lean on board tabs and filters once the card count grows. Without a board, the queue lives in your head. You remember which chat had the migration task, which terminal held the refactor, and what was supposed to happen after both. That works up to about three tasks. Columns, filters, and dependency badges are how the queue survives past that. ## Further reading - [The issue board](/docs/desktop/issue-board): columns, multi-select, board tabs, and sorting. - [Labels and filtering](/docs/desktop/labels-and-filtering): per-repo labels and how filters stack. - [Dependencies](/docs/desktop/dependencies): blocking, cycle detection, and queue automation. - [Deep links](/docs/desktop/deep-links): pre-filled issue drafts from other tools. [See the issue board docs](/docs/desktop/issue-board) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/issue-board-for-ai-agents --- # Run agent fleets on your Claude subscription > Lanes runs the official CLI in a real terminal, so parallel sessions draw from the plan you already pay for, not a separate meter. Many agent tools drive Claude through the Agent SDK or an API key, which meters usage separately from your plan. Run several at once and the cost climbs fast. Lanes takes a different path. ## How Lanes does it - **The real command.** Lanes opens a real terminal and runs the official `claude` command, exactly as you would by hand. A [session](/docs/desktop/sessions) is a real pseudo-terminal booted from your login shell with your dotfiles, not an SDK call dressed up as one. - **Interactive, not programmatic.** From the provider's side, a Lanes session is interactive CLI use, so it draws from your existing subscription. Anthropic's June 2026 change moved Agent SDK and `claude -p` usage onto a separate monthly credit; interactive Claude Code in the terminal stayed on plan limits, and that is the surface every Lanes session runs on. - **No proxy.** Lanes does not route your requests through its servers. You keep paying your provider directly. - **Same for Codex.** The official CLI, your login, your plan. The same holds when sessions stop and restart. Lanes relaunches Claude Code with `--resume`, so the conversation continues under the same login instead of starting a metered job somewhere else. And because Lanes launches the CLI from your shell, your existing auth just works; there is no API key to create and nothing new to pay for on the provider side. You can also see what you are using. Each issue's Meta tab tracks tokens, cost, and runtime per [session](/docs/desktop/sessions), and the status bar totals tokens and estimated cost across everything running. Per-repo [settings](/docs/desktop/settings) control the flags and environment variables sessions launch with, and Lanes records what each session actually ran with. What Lanes adds on top of the CLI is the workspace: parallel sessions, worktrees, and the board. [Lanes with Claude Code](/compare/lanes-with-claude-code) covers that side. ## Why it matters You can run a fleet of parallel agents without a surprise metered bill. Your plan keeps doing what it did before. This is an architecture property, not a pricing trick. Tools built on the Agent SDK got repriced in June 2026 because of what they are built on; tools that run the interactive CLI did not. If the line moves again, which side you are on is decided by the runtime, and the runtime here is the official CLI in a terminal. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Check that `claude` works in your terminal** and is logged in to your plan. Lanes runs the same binary with the same auth. 2. **Add a project and create an issue** with Cmd+N. 3. **Start a session** with Plan or Implement and watch the Meta tab count tokens against your plan, not a separate meter. The [quick start](/docs/desktop/quick-start) covers the first run in about two minutes. ## Further reading - [Anthropic just split the bill](/blog/claude-billing-split): what changed in June 2026, who it hit, and why Lanes did not have to change. - [Working with sessions](/docs/desktop/sessions): the PTY model, resume behavior, and per-session metrics. - [Settings](/docs/desktop/settings): per-repo CLI flags and environment variables. - [Lanes with Claude Code](/compare/lanes-with-claude-code): the workspace Lanes builds around the CLI. Read the [full breakdown](/blog/claude-billing-split) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/keep-your-claude-subscription --- # Run agents on GitHub and Linear issues > Pull tickets straight onto the board, run an agent on each, and write results back as pull requests and comments. Your work already lives in GitHub and Linear. Lanes connects to both, so you can turn a ticket into a running agent session without retyping anything. ## How Lanes does it - **Import tickets.** Pull issues from GitHub or Linear directly onto the board. The import dialog lists the first 50 open issues per repo or team, filters out pull requests and completed tickets, and pins the exact match when you search by number (`#9`, `9`, or `ENG-9` on Linear). - **Run with context.** Start a session on an imported ticket and the agent picks up the issue details. Import copies the title and body onto a draft [issue](/docs/desktop/issue-board), adds a marker line pointing back at the original, and shows an external link badge in the detail panel that jumps upstream in one click. - **Write results back.** Pull request links and comments flow to the source. Once connected, an agent can comment on the upstream GitHub or Linear issue through the MCP tools, posting the PR link when the session lands; the PR itself comes from `gh` inside the session. - **Isolated per ticket.** Each runs in its own worktree, so a sprint of tickets runs in parallel without conflicts. Both connections are OAuth, done once from Settings. [GitHub](/docs/desktop/github-integration) asks for the `repo` and `read:user` scopes; [Linear](/docs/desktop/linear-integration) uses a read-scope grant that also lets Lanes create issues and post comments. Tokens are stored locally in `integrations.json` and never leave your machine, and agents use the connection without ever seeing a token. If a token gets revoked, a one-click Reconnect banner restores the connection. Tickets drift, so imports can follow. If the upstream issue was edited after import, the refresh button on the external link badge re-fetches it, and Lanes warns before overwriting any local edits. The limits are documented plainly: pull request creation is not exposed through the MCP tools, and Linear states and priority are not mapped automatically, so you decide where an imported ticket lands on the board. There is also a zero-click path in the other direction: [deep links](/docs/desktop/deep-links). Linear's coding-tools setting can point at a `lanes://new` URL, so the coding-tool button on any Linear issue opens Lanes with a draft already populated, no import dialog involved. ## Why it matters The loop from ticket to pull request closes inside one workspace. Your team keeps planning in GitHub and Linear, and Lanes handles the local execution. Nothing about your tracker changes. The ticket stays the source of truth; Lanes holds the working copy, the worktree, and the session, then reports back where the team already looks. ## Set it up 1. **Connect the tracker.** Open Settings, then GitHub or Linear, and click Connect. Approve the OAuth screen in your browser. 2. **Import a ticket.** Click Import from GitHub or Import from Linear in the project header, search, and click the ticket. 3. **Start a session** on the imported issue with Plan or Implement. The copied title and body become the agent's context. 4. **Report back.** Have the agent comment the result upstream via MCP, or do it yourself from the external link badge. ## Where this breaks without Lanes Without the connection, every ticket is copy-paste: title into the terminal, body into the prompt, then the reverse trip to carry results back, once per ticket. Nothing links the local branch to the upstream issue, so a sprint of ten leaves you reconciling by hand. Import once, run in parallel, comment back is the whole point. ## Further reading - [GitHub integration](/docs/desktop/github-integration): the OAuth flow, import dialog, and agent-side tools. - [Linear integration](/docs/desktop/linear-integration): token refresh, the team picker, and search shorthand. - [Deep links](/docs/desktop/deep-links): pre-filled drafts straight from a Linear button or a script. - [The issue board](/docs/desktop/issue-board): where imported tickets live and run. [See the GitHub integration](/docs/desktop/github-integration) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/run-agents-on-github-and-linear-issues --- # A local, private AI coding workspace > Agents run on your machine with your own CLI auth. Nothing is proxied through our servers. For a lot of teams, where the code runs matters as much as how well the agent codes. Lanes is local-first: the workspace, the agents, and your code stay on your machine. ## How Lanes does it - **Local execution.** Sessions run the official CLIs in real terminals on your Mac. Lanes is a desktop app, macOS Ventura or later, native on Apple Silicon and Intel. - **Your own auth.** Lanes uses your existing CLI login. Requests are not proxied through Lanes servers. Tracker connections follow the same rule: GitHub and Linear OAuth tokens are stored locally in `integrations.json` and never leave your machine. - **Lanes Desktop MCP server.** The server that lets agents manage the board runs locally too, as SSE on `localhost:5353`. It listens on localhost only, which is why no key is involved. - **Encrypted.** The data Lanes does store is encrypted in transit and at rest. The workspace part is real, not a wrapper around a terminal. A built-in [file browser](/docs/desktop/file-browser) shows a lazy-loaded tree of every project you add and opens files in a Monaco editor, the same engine as VS Code, with tabs and Cmd+S saving. The [git viewer](/docs/desktop/git-integration) diffs each worktree against its base branch, side by side, in Changes and History modes. A [databases explorer](/docs/desktop/databases) finds the SQLite files in your working folder and runs read-only queries against them, safe to point at a database an agent is actively using. And the [process manager](/docs/desktop/process-manager) lists every CLI process Lanes knows about, including orphans from a crash and external agents it did not start, so nothing runs on your machine without showing up somewhere. All of this reads and writes your disk directly. There is no sync step, no cloud IDE, no copy of your repo living anywhere else. ## Why it matters You get parallel agent orchestration without handing your source or your credentials to a middleman. Oversight and privacy in the same tool. It also keeps review honest. When the diff viewer, the file tree, the database explorer, and the terminal all point at the same local directory, what you review is what runs. Verifying an agent's work never depends on someone else's rendering of it. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Grant folder access** and add your projects. The Permissions tab in [settings](/docs/desktop/settings) verifies Lanes can read and write each one. 2. **Start a session** on an issue. The agent runs in a real terminal against your local checkout. 3. **Review locally.** Open the changed files in the file browser, check the diff in the git viewer, and query the database if the task touched one. The [quick start](/docs/desktop/quick-start) walks through the first run in about two minutes. ## Where this breaks without Lanes The usual trade is oversight for locality: cloud agent platforms give you a dashboard by moving execution to their machines, and staying local has meant giving the dashboard up. Lanes keeps the dashboard and the execution in the same app on the same machine, so you never make that trade. ## Further reading - [File browser and editor](/docs/desktop/file-browser): the project tree and Monaco editor. - [Databases explorer](/docs/desktop/databases): read-only SQLite queries next to your sessions. - [Git integration](/docs/desktop/git-integration): local diffs against the base branch. - [Process manager](/docs/desktop/process-manager): every agent process on your machine, visible. - [Trust](/trust): how Lanes handles data and security. [See how Lanes handles trust](/trust) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/local-ai-coding-workspace --- # Loop engineering > Design loops that prompt your agents for you: drive the board over MCP, wait on session status, verify with diffs and tests, and drain a backlog into PRs. Loop engineering is the practice of replacing yourself as the person who prompts the agent: you design a system that finds work, runs it, checks it, and repeats, only pulling you in when judgement is needed. A loop needs durable state, isolated execution, and a reliable done-signal. Lanes provides all three out of the box. <figure className="not-prose my-8"> <svg viewBox="0 0 680 910" role="img" width="100%" xmlns="http://www.w3.org/2000/svg" className="mx-auto h-auto w-full max-w-[680px] font-sans text-muted-foreground"> <title>Agentic engineering loop A continuous implementation loop pulls ready Linear issues into Claude sessions in isolated worktrees, runs an implement and test cycle with three exits, passes through a human review gate, and feeds a release loop that promotes merged work to production. Implementation loop · runs continuously ↻ Linear backlog tagged ready-for-agent Spawn Claude session one per issue, new worktree Implement code in git worktree Tests and checks lint, build, test suite on failure, revise ↻ Ready for review conditions met Needs human paused for input Hard stop limit or budget hit Human gate Review and merge human reviews, merges Release loop · runs continuously ↻ Merged items queued for release Release candidate build and test again Promote to prod after checks pass ↻ failed release checks return for fixes success path needs human terminal stop
One feature, drained from a Linear backlog: each issue runs in its own worktree and is verified before it advances, three ways a run can end, and a human gate before the release loop ships it.
## How Lanes does it A designed loop has five parts: a **skill** that defines the work, a **verifier** it cannot fake, **state** it carries between runs, **guardrails** on what it can touch, and **hard stops** so it cannot run away. Lanes gives you all five out of the box, and the diagram above is the loop they make. - **The board is the loop's memory.** Issues and columns are shared state that survives any single conversation, so a loop compounds progress instead of starting cold each run. The [issue board](/use-cases/issue-board-for-ai-agents) is what the loop reads and advances. - **Sessions are the workers.** Over the [Lanes Desktop MCP server](/docs/desktop/local-mcp), an agent can start, stop, and resume a coding session on any issue, in plan mode or with a custom prompt. - **Status is the signal.** Lanes classifies every session as busy, waiting for input, or finished. The loop's rule is one line: a run is done, or needs you, when it is `awaiting_input`, `exited`, or `stopped`. Poll that cheap status, not the raw terminal. - **Worktrees keep runs isolated.** Each issue gets its own [git worktree](/use-cases/git-worktrees-for-ai-agents), so ten looped sessions never collide on files or context. - **Verification is first-class.** The loop can read the git diff per issue, run the tests, or start a second session whose only job is to review the first one's work before anything moves to review. The patterns follow from those pieces: wait until done, babysit a fleet on a cadence, fix until the tests are green, or drain the whole backlog with one session per issue, each in its own worktree, each opening a pull request when it finishes. All of them are one prompt away; [Building Loops](/docs/desktop/loops) walks through each with copy-pasteable prompts and a runnable driver. ## Why it matters Prompting an agent gets you a good first turn. A designed loop gets you the tenth turn without drift, because every iteration is checked against something objective before the next one starts. And the brakes are built in: a timeout, an iteration cap, and a terminal status stop the loop, while the review column keeps a human sign-off between the loop and anything that ships. ## Set it up 1. **Enable the server.** In Lanes, open Settings, then Local MCP, and toggle it on. 2. **Connect your agent.** One-click buttons write the `lanes-desktop` entry for Claude Code or Codex; any MCP client can point at the SSE endpoint on port 5353. 3. **Paste a loop.** Start small: "Check my running Lanes sessions every few minutes and tell me in one line what each one needs. Stop when nothing is left running." 4. **Scale it up.** Point the loop at a column: one session per backlog issue, each in its own worktree, a PR per issue, results waiting in review. ## Where this breaks without Lanes Loops without a shared workspace end up as scripts: state in JSON files, progress in logs, and no way to see what is running or why. When an iteration goes wrong you reconstruct it from stdout. On the board, every run the loop starts is a card with a live terminal, a status, a diff, and a history you can click. ## Further reading - [Building Loops](/docs/desktop/loops): the status rule, the loop patterns, and a runnable driver. - [Loop Engineering: Stop Prompting, Start Looping](/blog/loop-engineering-with-lanes): the thinking behind the practice. - [Lanes Desktop MCP](/docs/desktop/local-mcp): the tool surface the loop drives. - [Multi-agent coding orchestration](/use-cases/multi-agent-coding-orchestration): agents dispatching agents on the same board. - [Git worktrees for AI agents](/use-cases/git-worktrees-for-ai-agents): the isolation layer under every looped session. [Build your first loop](/docs/desktop/loops) or [get Lanes Desktop](/desktop). Source: https://lanes.sh/use-cases/loop-engineering --- # Run coding agents on a local model > Point a session at a model running on your own machine. Lanes manages Ollama end to end and sizes the context window so a real agent prompt actually fits. A coding agent does not have to send your prompt anywhere. Lanes can run a session against a model on this machine, with no tokens bought and nothing leaving the laptop. Ollama is the provider Lanes manages directly, so installing it, starting it, pulling models, and wiring a session to it all happen from one page. It is marked Research Preview, which means it works and the rough edges are the ones we most want to hear about. ![The Local LLMs settings page in Lanes, showing the Ollama status card and a table of installed and recommended models with Details, Size, and Fit columns](/assets/whats-new-v046/local-llms-settings.png) ## How Lanes does it - **The whole Ollama lifecycle.** Install, Start server, Stop server, and Uninstall, each using the right lever for how Ollama got here: a Homebrew formula is driven through `brew services`, the desktop app is opened and quit as an app. You do not have to remember which one you have. Everything runs in a real terminal, so you see what happened rather than a spinner. - **Your installed models, honestly.** Size on disk, parameter count, quantization, capabilities, and trained context length. The list works with the daemon stopped, because Lanes reads the manifests in `~/.ollama` instead of showing you an empty page. - **A curated list sized for this machine.** Recommended models carry a checked download size and a minimum memory figure, compared against the RAM you actually have. Below that sits the full ollama.com library, searchable, and a field that pulls any reference by name. - **A Fit column that answers the real question.** Before you spend the download: a context figure such as `128k context` means it fits with room, `Fits at 64k, little headroom` means you are close to the edge, and `Needs ~38k, this machine fits ~24k` means it cannot hold a session prompt here. `No tool support` means it cannot run an agent at any context size, because an agent that cannot call tools cannot edit files. Fit warns, it does not block. ## Why context is the thing that decides This is the part that catches people out. A real session sends roughly **38,000 tokens before you type anything**: the harness instructions, its built-in tool definitions, and the tool definitions of every MCP server you have connected. Ollama picks a context size from available memory, commonly far below that, and when a prompt overflows it truncates instead of failing. It keeps the head and the tail and drops the middle, so what reaches the model is tool-definition JSON with the instructions cut out. The model answers with something that looks like nonsense, and nothing anywhere reports the loss. So Lanes sizes the context itself and bakes it in. On pull it creates a variant tagged `-lanes` with `num_ctx` set from your total memory and that model's own key/value geometry. The variant shares its data with the base model, so it costs nothing extra on disk, it survives reboots, and `ollama rm` undoes it. A Context window field overrides the estimate outright. If a model still does not fit, the lever that helps most is connecting fewer MCP servers. Their tool definitions are a large share of that 38,000. ## Why it matters Marginal cost goes to zero. A model on your own machine has no meter attached, so the sessions you would think twice about starting, the mechanical refactor or the fifth attempt at a flaky test, stop having a price. Nothing is sent to a provider, which settles the client-code question before it is asked. And you keep the workspace: the same board, the same [worktree](/docs/desktop/worktrees) per session, the same branch diff to review. It also wires itself in. Once Ollama is serving and has at least one model, Lanes registers it as a [Gateway](/docs/desktop/gateway) provider automatically, never overriding a provider you picked yourself. The session picker's **Model** row then lists the tags your daemon really has, rather than model ids it does not serve. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Open Settings, Agentic Coding, Local LLMs** and hit **Install** if Ollama is not here yet. Lanes installs it with Homebrew and starts the server for you. 2. **Pick a model with a green Fit.** Browse the recommended list, check the Fit column against the download size, and pull. Lanes bakes the `-lanes` variant as part of the pull. 3. **Start a session and choose the tag** in the **Model** row. The **Gateway** row will already be pointing at your daemon. A shortcut in the app header, next to the keep-awake button, shows live daemon state and starts or stops the server without opening Settings. ## Further reading - [Local LLMs](/docs/desktop/local-llms): the full page, including the Fit rules and re-baking. - [Gateway](/docs/desktop/gateway): how a session is pointed at a provider. - [Point sessions at any model provider](/use-cases/bring-your-own-model-provider): the same mechanism aimed at hardware you own or tokens you buy. - [Run a fleet on mixed models](/use-cases/mixed-model-agent-fleets): local and frontier models on one board. - [A local, private AI coding workspace](/use-cases/local-ai-coding-workspace): the rest of what stays on your machine. [Get Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/use-cases/run-agents-on-local-models --- # Point sessions at any model provider > Run Claude Code or Codex against a provider you host or buy: a box on your network, OpenRouter, z.ai GLM, or anything serving the same wire format. Lanes is never in the request path. The CLI you run does not have to use the provider it shipped with. A **provider profile** in Lanes points a session at a model provider of your choosing, and the harness stays exactly the same. Only where its requests go changes. That covers three shapes: a model on your own machine, your own hardware on the network such as a vLLM box, and a provider you buy by the token like OpenRouter or z.ai GLM. ![The Gateway settings page in Lanes, showing a list of provider profiles with base URL, auth mode, token, custom headers, and the Test connection button](/assets/whats-new-v046/gateway-settings.png) ## How Lanes does it - **Presets, then one field.** Start from Ollama, LM Studio, vLLM, OpenRouter, or z.ai GLM and the base URL and auth mode are filled in, or add a blank profile and type your own. Adding a profile selects it, so there is nothing else to switch on. - **Lanes is not in the request path.** A profile is applied as real process environment when the session starts, so the request goes straight from the CLI to your provider. Nothing routes through us, and the credential never reaches terminal scrollback, the command line, or `ps`. - **Per session, not per app.** Switch providers in the **Gateway** row of the session settings picker, right next to Model. A profile with no base URL does nothing at all, so the half-filled one you are still typing into cannot affect a session that is already running. - **A probe that tells you something.** **Test connection** sends a single one-token request. An "unknown model" result counts as success: the probe uses a placeholder model name on purpose, so the provider authenticated the request before rejecting the name, which is exactly what is being checked. - **One profile, either harness.** A profile does not name a CLI. The session already chose one, so Lanes derives the right environment variable names at launch. Claude Code is the harness whose names Lanes knows today; other CLIs are served through the profile's free-form environment table. ## Name the model The profile deliberately has no model field, because Lanes already has one: the **Model** row in the session picker. Type your provider's model id into the custom field there. `Default` is the one guaranteed-wrong pick. With no model flag set, the harness sends its own default model id, which your provider will not serve any more than the other first-party ids. While a profile is active, that row stops offering the built-in ids for the same reason. One extra worth setting through the profile's **Extra environment** table is `ANTHROPIC_DEFAULT_HAIKU_MODEL`, because Claude Code uses Haiku for background work like conversation titling, and those calls otherwise fail quietly. ## Prefer Bearer The two auth modes differ only in which header carries the credential, `Authorization: Bearer` or `x-api-key`. Prefer Bearer. Most providers, OpenRouter and z.ai included, only read the Authorization header, even though they call the credential an API key. API key mode has a second problem that Test connection cannot catch. Claude Code asks you to approve an API-key variable once per machine, and the prompt's default answer is *No*. Decline it and the key is ignored from then on, so the session falls back to your saved claude.ai login and sends that to your provider, which rejects it with a `401`, while Test connection still passes because Lanes never hits the approval gate. If you see a session `401` on a profile that tested fine, switch to Bearer. ## What you give up Routing a harness away from its own provider costs some features, and it is better to know before you start. The most common failure is a `400` on adaptive thinking, because Claude Code treats a model name it does not recognise as one that accepts adaptive reasoning. Lanes ships **Disable adaptive thinking** and **Disable experimental betas** on by default to cover that. Beyond it: web search is unavailable, prompt caching depends entirely on the provider, and Remote Control and voice dictation need a claude.ai identity. MCP itself keeps working, including the Lanes server. ## Why it matters Your subscription is not the only meter that can run a fleet. A profile lets a session use hardware you already own, or a model that is simply better at the thing in front of you, without changing the CLI, the board, or the [worktree](/docs/desktop/worktrees) isolation around it. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Open Settings, Agentic Coding, Gateway** and add a profile from a preset. Fill in the base URL and the token, leaving auth mode on Bearer. 2. **Hit Test connection.** An unknown-model response is a pass. 3. **Start a session and set the Model row** to a model id your provider serves. The **Gateway** row picks the profile, or `None` to go back to the CLI's own provider. ## Further reading - [Gateway](/docs/desktop/gateway): the full page, including bearer versus API key and the advanced Claude Code toggles. - [Run coding agents on a local model](/use-cases/run-agents-on-local-models): the managed Ollama path, with context sizing handled for you. - [Run a fleet on mixed models](/use-cases/mixed-model-agent-fleets): different providers on different sessions, one board. - [Working with sessions](/docs/desktop/sessions): where the Gateway and Model rows live. [Get Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/use-cases/bring-your-own-model-provider --- # Run a fleet on mixed models > Provider and model are per-session settings, so one board can run a local model on the mechanical work and a frontier model on the hard issue, with the same isolation and the same review. Most tools make you pick one model and live with it. In Lanes the provider and the model are chosen per session, which means a single board can span them. The rename job runs on a model on your laptop while the tricky migration runs on a frontier model on your subscription, both in their own worktrees, both reviewed the same way. This is a workspace-shaped capability. It only means anything when you are running several agents at once. ## How Lanes does it - **Three rows, set per session.** The launch flyout carries **Harness**, **Model**, and **Gateway**, so each session picks its CLI, its model, and where the requests go. Nothing is global, and nothing has to be changed back afterwards. - **Isolation does not care who served the tokens.** Every session still gets a git [worktree](/docs/desktop/worktrees) and branch off your base branch, created and cleaned up automatically. A cheap local session cannot tread on an expensive one. - **Cost is visible per session.** The Meta tab tracks tokens, cost, and runtime on each one, so the question "was that worth doing on the big model" has an answer on the card rather than in a monthly invoice. - **Review is uniform.** Whatever produced the diff, it lands as a branch you read in the [Changes pane](/docs/desktop/git-integration) before it merges. The model choice is an input to the work, not a reason to trust it more or less. ## A split that works There is no scheduler doing this for you, and that is deliberate. You choose, per issue, and a few defaults hold up well: - **Local models for volume.** Lint fixes, mechanical renames, test scaffolding, dependency bumps, first drafts of a docs page. Marginal cost is zero, so a retry costs nothing but time. Respect the Fit column: a session sends roughly 38,000 tokens before you type, so a model that cannot hold that will produce confident nonsense rather than an error. [Running agents on a local model](/use-cases/run-agents-on-local-models) covers why. - **Your subscription for the work that needs judgment.** The architectural change, the bug nobody has reproduced yet, the plan-mode run you want to be right before any code is written. Sessions on the CLI's own provider are the real binary in a real terminal, which is interactive use, so they draw on the plan you already pay for. - **A bought provider for burst or for a specific model.** A [provider profile](/use-cases/bring-your-own-model-provider) pointed at OpenRouter, z.ai GLM, or a vLLM box on your network covers the cases where you want capacity now, or want one particular model for one particular task. You can also run the same issue twice, on two different models, and keep the diff you prefer. Sibling sessions share the issue's worktree but keep separate transcripts and metrics, which is the same move as running [Claude Code and Codex side by side](/use-cases/claude-code-and-codex-side-by-side), one level down. ## Why it matters The usual argument about local models is whether they are good enough to replace a frontier model. That is the wrong question for a fleet. Plenty of the work on a real board is mechanical, and paying frontier prices for it is a choice you only make because your tool made model selection a global setting. Splitting the fleet also changes what you are willing to start. When a third of the board costs nothing to run, the speculative refactor and the second opinion stop needing justification. The board stays the control surface either way: same columns, same statuses, same bell when a session wants you, regardless of what is answering it. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Set up one local model** in Settings, Agentic Coding, Local LLMs. Lanes registers the daemon as a Gateway provider once it has a model to serve. 2. **Start two sessions on two issues.** On the mechanical one, set **Gateway** to your local daemon and pick the tag in **Model**. On the hard one, leave **Gateway** on the CLI's own provider. 3. **Watch the Meta tab** on both. Tokens, cost, and runtime per session are what tell you whether the split is set correctly. ## Further reading - [Run coding agents on a local model](/use-cases/run-agents-on-local-models): the managed Ollama path and the context problem. - [Point sessions at any model provider](/use-cases/bring-your-own-model-provider): profiles for hardware you own or tokens you buy. - [Run agent fleets on your Claude subscription](/use-cases/keep-your-claude-subscription): why CLI sessions stay on plan limits. - [Working with sessions](/docs/desktop/sessions): the launch flyout, sibling sessions, and per-session metrics. [Get Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/use-cases/mixed-model-agent-fleets --- # Keep your coding CLIs current > See whether Claude Code and Codex are installed, which version you are on, and where they came from. Updates run through the package manager that actually installed them. Lanes runs the official coding CLIs in a real terminal, so having one installed is the entire setup. The catch is that "installed" is a fuzzier state than it sounds: there may be two copies on the machine, the one on your PATH may not be the one you think, and the update command you reach for may be updating a copy that never runs. The **Harness** page makes that state visible for Claude Code and OpenAI Codex, and keeps it current from the same screen. ## How Lanes does it - **PATH status and version.** Resolved with `command -v`, alongside whatever version the CLI itself reports when asked. No guessing from a lockfile. - **The resolved path, and who put it there.** The binary path plus the package manager behind it: npm global, bun global, pnpm global, yarn global, Homebrew, a native installer, or an unrecognised install. The path is shown for diagnosis, not for editing. Lanes launches whatever your PATH resolves, so if you want a different build, change what your shell resolves. - **Updates through the right manager.** This is the part that matters more than it sounds. bun, pnpm, and yarn each keep global packages in their own prefix, so running `npm install -g` against a bun-installed CLI reports success while the old binary keeps right on running. Lanes matches what it detected: `bun add -g` for bun, `pnpm add -g` for pnpm, `brew upgrade --cask` for Homebrew, `claude update` for a native Claude Code install. - **Install only when it is missing.** Install prefers Homebrew when it is available and falls back to npm. Homebrew goes first because it matches how Lanes itself installs, it is revertible, and it avoids piping a downloaded script into a shell. - **Both actions run in a real terminal.** You watch the output rather than a spinner, and if something goes wrong you have the error rather than a red toast. ## No verdict beats a wrong verdict Lanes reads the latest published version from the package registry. If the registry cannot be reached, because you are offline or it is having a bad day, the card shows your installed version and offers no verdict at all. An unreachable registry means "no update to offer". It never means "you are behind", and it never becomes a bare claim that you are up to date. `up to date` is shown only when there was a real answer to compare against, which is the only way that label is worth anything. ## Why it matters Version drift is quiet. A CLI that is four releases old still starts, still answers, and still writes files; it just does not have the model you thought you were selecting, or the flag your teammate's setup depends on. When you are running several sessions at once, that drift is multiplied across every agent on the board, and the symptom shows up as "the agent is being weird today" rather than as a version number. The manager-mismatch trap is the same problem with a sharper edge, because it produces a successful-looking update that changes nothing. Detecting the manager and using it removes a class of confusion that is genuinely hard to debug from the outside. ## Set it up Install Lanes and open it: ```bash brew install --cask lanes-sh/lanes/lanes && open -a Lanes ``` Then: 1. **Open Settings, Agentic Coding, Harness.** One card per CLI, with PATH status, version, resolved path, and detected manager. 2. **Act on the amber chip.** `· v2.2.0 available` means there is a newer release; Update runs the right command in a terminal you can read. 3. **Use Recheck after anything external.** Installing a CLI outside Lanes, or switching Node versions, changes what your PATH resolves. If you run Codex, the card also carries an optional one-click block that adds `[tui] terminal_title` to `~/.codex/config.toml`. Idle detection already works from Codex's transcript; this adds a second signal, and Lanes writes only that block and leaves the rest of the file alone. ## Further reading - [Harness](/docs/desktop/harness): the full page, including the per-manager update table. - [Working with sessions](/docs/desktop/sessions): choosing the harness, model, and effort per session. - [Settings](/docs/desktop/settings): the Agentic Coding group and per-repository flags. - [Run Claude Code and Codex side by side](/use-cases/claude-code-and-codex-side-by-side): why you might keep both current. [Get Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/use-cases/keep-coding-clis-current --- # Add a waitlist form without a backend > Launch a waitlist in minutes. One request gives you a live endpoint that captures signups, with no server to build or host. A waitlist is the classic "I just need to collect emails" problem, and it should not require standing up a backend. Lanes Forms gives you a live endpoint in one request. That request is a `POST /v1/forms` with a `schema`, which for a waitlist is a single required email field. The response returns an `endpoint_url` your page can post to, plus a claim link that makes the form yours later. There is no signup before the first submission, and the endpoint starts collecting the moment it exists. The [quickstart](/docs/forms/quickstart) walks the whole path in three steps. ## How Lanes Forms does it - **Provision in one call.** One POST returns a live form endpoint, no signup. Only `schema` is required, with field types for text, email, textarea, number, checkbox, and hidden. Every call creates its own form; send an `Idempotency-Key` when you want a rerun to replay the same form for 24 hours instead of creating a duplicate. Every option is covered in [provisioning](/docs/forms/provisioning). - **Collect immediately.** Submissions are captured from the first second and stored for you. While the form is unclaimed it holds up to 25 submissions and forwards nothing, which is enough to launch the page today and sort out ownership tomorrow. - **Point your page at it.** Wire a plain HTML form or a fetch to the endpoint. A plain form post redirects the visitor to a hosted thank-you page, no JavaScript needed; a fetch call gets a JSON receipt with a submission id instead. Ready-made snippets are in [integrating](/docs/forms/integrating). - **Claim when ready.** Claim the form into your dashboard by email to manage submissions and export them. Claiming releases every held signup and turns on email forwarding, with your first recipient pre-verified. An unclaimed form freezes after 7 days, so claim before then. See [claiming](/docs/forms/claiming). ## Spam handled Honeypots, per-IP rate limits, and disposable-email rejection keep junk out of your list without any extra setup. The hidden `_gotcha` field silently marks bot submissions as spam while still returning success, so the bot learns nothing. Submissions are limited to 10 per minute per IP, request bodies are capped at 64 KB, and IP addresses are stored only as salted hashes. You can also lock the endpoint to your own site with `allowed_origins`: a browser post from any other origin gets a 403, and CORS responses echo your exact origin, never a wildcard. See [securing your endpoints](/docs/forms/securing-your-endpoints). ## Set it up 1. **Provision the endpoint.** One curl, no auth. The response includes the `endpoint_url`, and a claim link is emailed to your first recipient. ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Content-Type: application/json" \ -d '{ "recipients": ["you@company.com"], "allowed_origins": ["example.com"], "schema": [ {"name": "email", "type": "email", "required": true} ] }' ``` 2. **Point your page at it.** A plain HTML form is enough. Keep the hidden honeypot input. ```html
``` 3. **Claim it.** Open the claim link, sign in, and every signup collected so far lands in your dashboard. Forwarding to your inbox starts at the same moment. Without this, a waitlist means writing and hosting a handler, provisioning storage, filtering bots, and keeping all of it alive for a page that may be gone in a month. The endpoint-first flow inverts that: the form exists in seconds, and it only earns an account when the signups prove it should. ## Further reading - [Quickstart](/docs/forms/quickstart): provision, wire, and claim in three steps. - [Provisioning a form](/docs/forms/provisioning): every option on the create call, plus the unclaimed limits. - [Claiming a form](/docs/forms/claiming): how held submissions release and forwarding turns on. - [Securing your endpoints](/docs/forms/securing-your-endpoints): origin allowlists, per-form CORS, and the built-in defenses. - [Lead capture and newsletter signups](/use-cases/lead-capture-and-newsletter-signups): the same pattern, pointed at your marketing stack. [See Lanes Forms](/forms) or read the [quickstart](/docs/forms/quickstart). Source: https://lanes.sh/use-cases/waitlist-form-without-a-backend --- # Contact and support forms > Add a contact or support form that emails you every submission, with spam defense built in. Every site needs a contact form, and every contact form needs somewhere for the message to go. Lanes Forms gives you the endpoint and the delivery, without a backend. The shape of the problem never changes: a name, an email, a message, and a route to your inbox. Lanes Forms hosts that as a `POST` endpoint you create in one request. Your page posts to it, the visitor lands on a hosted thank-you page, and the message is stored and forwarded. The create call is one `POST /v1/forms` with a `schema`, described in [provisioning](/docs/forms/provisioning). ## How Lanes Forms does it - **One endpoint.** Provision a form with the fields you need: name, email, message. Schemas support text, email, textarea, number, checkbox, and hidden fields, and required fields are enforced at submit time with a `422` that lists exactly which fields were missing. - **Email forwarding.** Add recipients and every submission lands in your inbox. A form forwards to up to 5 recipients, each confirmed by a verification link before any mail reaches them, and spam is never forwarded. When you claim the form, your first recipient is verified for you. Delivery is covered in [integrating](/docs/forms/integrating). - **Spam defense.** Honeypots, rate limits, and disposable-email blocking, on by default. The hidden `_gotcha` field silently absorbs bots while still returning success, submissions are limited to 10 per minute per IP, and request bodies are capped at 64 KB. The full endpoint contract is in [submitting](/docs/forms/submitting). - **Origin rules.** Lock submissions to your own domain so nobody abuses the endpoint. `allowed_origins` is a hostname allowlist for browser posts; anything else gets a `403`. Forms that only your server submits to can require an API key instead. See [securing your endpoints](/docs/forms/securing-your-endpoints). ## Where it goes Forward by email, keep it stored in Lanes, or route to a webhook or Supabase. Your choice, changeable anytime. Two of those destinations are live today: email forwarding and Lanes-hosted storage, which keeps every submission viewable and exportable from the dashboard. Webhook and Supabase delivery are on the roadmap; selecting them currently returns `422 action_not_available`. The full destination picture, live and planned, is in [route submissions to email, Supabase, or webhooks](/use-cases/route-submissions-email-supabase-webhooks). ## Set it up 1. **Provision the form.** One `POST /v1/forms` with your schema and your inbox as the recipient returns a live `endpoint_url`. 2. **Drop the form on your page.** The browser posts straight to the endpoint and the visitor is redirected to a hosted thank-you page, no JavaScript required. ```html
``` 3. **Claim it.** Open the claim link, sign in, and forwarding turns on with your first recipient already verified. Support mail starts arriving in your inbox, and everything held before the claim is released to the dashboard. The usual alternatives both leak. A `mailto:` link exposes your address to scrapers and depends on the visitor having a mail client configured. A hand-rolled handler means a server, a mail integration, and your own spam filtering, all for a form that carries a few messages a week. ## Further reading - [Integrating a form](/docs/forms/integrating): HTML, fetch, and server-side wiring, plus where submissions go. - [Securing your endpoints](/docs/forms/securing-your-endpoints): origin allowlists versus API-key mode, and per-form CORS. - [Submitting data](/docs/forms/submitting): the endpoint contract, the honeypot, and the limits. - [Route submissions to email, Supabase, or webhooks](/use-cases/route-submissions-email-supabase-webhooks): what is live today and what is on the roadmap. [See Lanes Forms](/forms). Source: https://lanes.sh/use-cases/contact-and-support-forms --- # Booking and inquiry request forms > Capture booking and inquiry requests on any site, route them where your team works, and keep spam out. Booking and inquiry forms are contact forms with intent: someone wants a slot, a quote, or a callback. Lanes Forms captures the request and routes it wherever your team works. The endpoint accepts JSON, form-urlencoded, and multipart posts, so it serves a plain HTML form and a custom widget equally well. A browser post redirects the visitor to a hosted thank-you page; an API caller gets a JSON receipt with a submission id. The contract is in [submitting](/docs/forms/submitting). ## How Lanes Forms does it - **Fields that fit.** Provision the exact fields you need, from dates to service type. Mark the ones that matter as required: a request that omits them is refused with `422 missing_required_fields`, and the response lists exactly which fields were empty, so half-filled requests never reach your calendar. - **Route the request.** Email it to your team, push to a webhook, or store it in Supabase. Email forwarding and Lanes-hosted storage are live today, and a form forwards to up to 5 verified recipients, enough for a whole front desk. Webhook and Supabase delivery are on the roadmap and return `422 action_not_available` until they ship. See [integrating](/docs/forms/integrating). - **Origin-locked.** Only your site can submit, so the endpoint cannot be scraped and spammed. Browser posts must come from a hostname in the form's `allowed_origins` or they get `403 origin_not_allowed`. The error even names the origin it received and the allowlist it expected, which makes misconfigurations short-lived. See [securing your endpoints](/docs/forms/securing-your-endpoints). - **No backend.** Nothing to run for a form that just needs to reach a person. The hidden `_gotcha` honeypot absorbs bots, submissions are rate limited to 10 per minute per IP, and IPs are stored only as salted hashes. ## Why it matters The request lands where you already work, protected from bots, without a server you have to maintain. An inquiry is also more perishable than a newsletter signup: a request nobody sees for three days is a booking somewhere else. Forwarding to verified inboxes plus a dashboard copy means the request is both delivered and kept. ## Set it up 1. **Provision the fields.** Create the form with the request fields your team needs, for example email, a preferred date, and a message, with your shared inbox as a recipient. See [integrating](/docs/forms/integrating) for the wiring options. 2. **Put the form on the page.** A plain HTML form posts straight to the endpoint: ```html
``` 3. **Claim it and add your team.** Claiming turns forwarding on with the first recipient pre-verified; every additional address confirms via an emailed link before it receives requests. 4. **Handle failures explicitly.** Every error is a stable machine-readable code in a common envelope, so your page can show a useful message instead of a dead end. See [errors](/docs/forms/errors). An inquiry form that fails silently costs real bookings, and the usual causes are a handler that quietly stopped sending mail and a spam filter nobody can see into. Here the default workflow is email plus Lanes storage, so a request is kept even if nobody reads the inbox that day, and spam is marked rather than dropped. ## Further reading - [Submitting data](/docs/forms/submitting): content types, required-field validation, and limits. - [Errors](/docs/forms/errors): the stable error envelope and every code you can meet. - [Securing your endpoints](/docs/forms/securing-your-endpoints): origin allowlists and API-key mode. - [Contact and support forms](/use-cases/contact-and-support-forms): the same pattern without the scheduling intent. [See Lanes Forms](/forms). Source: https://lanes.sh/use-cases/booking-and-inquiry-forms --- # Forms for prototypes and side projects > Ship the form part of a prototype in seconds. No backend, no signup, and claim it later if the idea sticks. When you are prototyping, the form is never the interesting part, but it still blocks the demo. Lanes Forms gets it out of the way: a live endpoint with no backend and no signup. The whole integration is one `POST /v1/forms` with a `schema`, the list of fields. The response returns a live `endpoint_url` plus a claim link, and the endpoint accepts submissions immediately. The [quickstart](/docs/forms/quickstart) covers the full loop in three steps. ## How Lanes Forms does it - **Zero setup.** One request and the form works. Only the field list is required; types are text, email, textarea, number, checkbox, and hidden. Details in [provisioning](/docs/forms/provisioning). - **No account to start.** Provision anonymously, then claim it later if the project grows. Possession of the claim link is the authorization: whoever opens it and signs in becomes the owner, so you can build the prototype and hand the link to whoever should run it. See [claiming](/docs/forms/claiming). - **Real submissions.** Data is captured and stored from the first send, so your demo is real. An unclaimed form holds up to 25 submissions, and claiming releases all of them into the dashboard, so nothing collected during the demo is lost. - **Cheap to abandon.** If the idea does not stick, there is nothing to tear down. An unclaimed form freezes after 7 days and its data is hard-deleted 30 days later. Walking away is the teardown. ## Why it matters Great for hackathons, landing pages, and "let me test whether anyone wants this" moments, where standing up a backend would cost more than the experiment is worth. It is also script-friendly. Every call creates its own form, so two prototypes that happen to share a schema stay separate. When you do want a rerun to be safe, send an `Idempotency-Key` with your call: repeating it replays the same form for 24 hours instead of leaving a growing pile of duplicates, which suits scaffolding tools and setup scripts that may run more than once. The demo loop is finished out of the box, too. A browser post lands the visitor on a hosted thank-you page with no JavaScript, and the claim link can be previewed without consuming it, so an email scanner prefetching the link cannot burn the token before you get to it. ## Set it up 1. **Provision the endpoint.** No auth, one request: ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Content-Type: application/json" \ -d '{ "recipients": ["you@company.com"], "allowed_origins": ["example.com"], "schema": [ {"name": "email", "type": "email", "required": true}, {"name": "message", "type": "textarea"} ] }' ``` 2. **Send a test submission.** Confirm the loop works before wiring any UI: ```bash curl -X POST https://api.lanes.sh/v1/f/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -d '{"email": "visitor@site.com", "message": "Hello"}' ``` 3. **Point the prototype at it.** Use a plain HTML form or a fetch call; the [quickstart](/docs/forms/quickstart) has both snippets. 4. **Claim it if the idea sticks.** Open the claim link, sign in, and the held submissions plus the form move into your dashboard. Prototype forms usually end one of two ways: a button that silently drops input because the handler was never written, or an evening spent on storage for an idea that dies by Friday. A held-and-claimable endpoint is the third ending. The demo collects real data from the first second, and only a form that earns a claim earns any ongoing existence. ## Further reading - [Quickstart](/docs/forms/quickstart): provision, wire, and claim in three steps. - [Provisioning a form](/docs/forms/provisioning): idempotency, unclaimed limits, and the form lifecycle. - [Claiming a form](/docs/forms/claiming): how ownership transfers by link. - [Let your AI agent provision a form](/use-cases/agent-provisioned-forms-mcp): have the agent create the endpoint mid-task. [See Lanes Forms](/forms). Source: https://lanes.sh/use-cases/forms-for-prototypes --- # Feedback and survey forms > Collect feedback and survey responses into one place, with export, without building a backend. Feedback and surveys are about getting answers into one place you can actually read. Lanes Forms handles capture, storage, and export so you can focus on the questions. A survey here is a schema: one `POST /v1/forms` with the list of questions as fields returns a live endpoint. Responses can arrive as JSON, form-urlencoded posts, or multipart data, from a page you own or a script you run. See [provisioning](/docs/forms/provisioning) for the create call and [submitting](/docs/forms/submitting) for the endpoint contract. ## How Lanes Forms does it - **Any fields.** Text, long text, numbers, and checkboxes, whatever the survey needs. Schema types are text, email, textarea, number, checkbox, and hidden, each with an optional required flag. A response that skips a required question is refused with a `422` that lists exactly which fields were missing. - **Stored and exportable.** Submissions are held in Lanes with CSV export. Responses are listed in the dashboard, and the submissions endpoint exports them as CSV with `?format=csv`, so the analysis happens in whatever tool you already use. See the [API reference](/docs/forms/api-reference). - **Route it too.** Send responses to email, a webhook, or Supabase as they arrive. Email forwarding and Lanes-hosted storage are live today; webhook and Supabase delivery are on the roadmap and return `422 action_not_available` until they ship. See [integrating](/docs/forms/integrating). - **Protected.** Rate limits and honeypots keep bots from skewing your results. A bot that fills the hidden `_gotcha` field is marked as spam silently, with success still returned, and each IP is limited to 10 submissions per minute. Every stored response carries a spam score, so junk stays separable from signal. ## Why it matters You get clean responses in one place, ready to read or export, without standing up and maintaining a survey backend. Surveys also change mid-flight. Values you post that are not in the schema are stored under `extra` rather than rejected, so adding a question to your markup does not break collection, and the schema itself can be updated later with a `PATCH` on the form. Both audiences are served by the same endpoint. A respondent submitting from a browser lands on a hosted thank-you page, while a script or API caller gets a JSON receipt with a submission id, so panel tools and embedded pages can feed the same survey. ## Set it up 1. **Provision the survey.** One request with the questions as fields: ```bash curl -X POST https://api.lanes.sh/v1/forms \ -H "Content-Type: application/json" \ -d '{ "recipients": ["you@company.com"], "allowed_origins": ["example.com"], "schema": [ {"name": "rating", "type": "number", "required": true}, {"name": "comments", "type": "textarea"} ] }' ``` 2. **Collect responses.** Point a page at the `endpoint_url`, or submit directly from any client: ```bash curl -X POST https://api.lanes.sh/v1/f/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -d '{"rating": 4, "comments": "Loved the onboarding"}' ``` 3. **Read and export.** Claim the form, watch responses arrive in the dashboard, and pull the CSV when you are ready to analyze. A dedicated survey tool earns its keep on branching logic and question banks. When the survey is five questions on a page you already own, it is overhead: another login, another embed script, and answers that live inside someone else's UI instead of next to your work. ## Further reading - [Provisioning a form](/docs/forms/provisioning): field types, required flags, and schema options. - [Submitting data](/docs/forms/submitting): accepted content types, validation, and what each response stores. - [API reference](/docs/forms/api-reference): the submissions endpoint and CSV export. - [Forms for prototypes and side projects](/use-cases/forms-for-prototypes): the same zero-setup endpoint for experiments. [See Lanes Forms](/forms). Source: https://lanes.sh/use-cases/feedback-and-survey-forms --- # Lead capture and newsletter signups > Turn any page into a lead capture or newsletter signup form, and route new contacts to your stack. Lead capture is a form plus a destination: an inbox, a CRM, a spreadsheet. Lanes Forms is the endpoint that connects the two without a backend. One `POST /v1/forms` with a `schema` returns a live `endpoint_url`, no signup first. For a signup form the schema is usually one required email field, and a `hidden` field can carry a campaign tag. Keys you post that are not in the schema are stored under `extra` rather than rejected, so adding a UTM parameter to your markup later does not break collection. See [provisioning](/docs/forms/provisioning) and [submitting](/docs/forms/submitting). ## How Lanes Forms does it - **One endpoint per form.** Provision it, drop it on the page, done. Only `schema` is required on the create call, and separate forms for separate campaigns cost one request each, so attribution stays clean. - **Route new leads.** Forward by email, push to a webhook, or write to Supabase. Email forwarding and Lanes-hosted storage are live today; webhook and Supabase delivery are on the roadmap and return `422 action_not_available` until they ship. Stored leads are viewable and exportable from the dashboard. See [integrating](/docs/forms/integrating). - **Keep the list clean.** Disposable-email rejection and rate limits reduce junk signups. Every form also reserves a hidden `_gotcha` honeypot: a bot that fills it is silently marked as spam while still receiving success, and spam is never forwarded to your inbox. Submissions are limited to 10 per minute per IP. - **Origin-locked.** Only your domain can submit to the endpoint. `allowed_origins` lists the hostnames allowed to post from a browser; any other origin gets `403 origin_not_allowed`, and an empty list rejects browser posts entirely. See [securing your endpoints](/docs/forms/securing-your-endpoints). ## Why it matters New contacts flow straight into the tools you already use, without a server in the middle and without the junk. Capture also does not wait for the destination. Submissions are stored from the first second, so the landing page can go live before you decide where leads should land. Claiming the form releases everything collected so far into the dashboard and switches on forwarding to your verified recipients, up to 5 per form. ## Set it up 1. **Provision the endpoint.** One `POST /v1/forms` with your email field and your domain in `allowed_origins`. The response includes the `endpoint_url`; see [provisioning](/docs/forms/provisioning). 2. **Wire your signup UI.** POST JSON from your own component and read back the receipt. Keep `_gotcha` present and empty; it is the honeypot. ```js const res = await fetch("https://api.lanes.sh/v1/f/YOUR_FORM_ID", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, message, _gotcha: "" }), }); const { ok, submission_id } = await res.json(); ``` 3. **Never put an API key in the page.** Browser posts are authorized by the form's `allowed_origins`, not by a secret, so there is nothing to leak. 4. **Claim and export.** Open the claim link, sign in, and manage the list from the dashboard, exporting whenever you want it somewhere else. ## Where this breaks without Lanes The DIY version is a serverless function, a table, and a queue of small chores: validation, deduplication, rate limiting, spam. Each one is an afternoon, and none of them is your product. The common shortcut, pasting a marketing tool's embed script into the page, couples your markup to their widget and their styling. An endpoint keeps the page yours. Plain HTML or one fetch call, junk filtered before it reaches the list, and the data captured from the first second. ## Further reading - [Provisioning a form](/docs/forms/provisioning): schema options, hidden fields, and the create call. - [Integrating a form](/docs/forms/integrating): fetch wiring and where submissions go today. - [Securing your endpoints](/docs/forms/securing-your-endpoints): origin allowlists and why browser forms need no key. - [Add a waitlist form without a backend](/use-cases/waitlist-form-without-a-backend): the same endpoint pattern for a prelaunch list. [See Lanes Forms](/forms). Source: https://lanes.sh/use-cases/lead-capture-and-newsletter-signups --- # Let your AI agent provision a form > Your coding agent can create a live form endpoint mid-task over MCP, with no signup, and hand you a link to claim it. When an agent is building your site, the form should not be a detour. With the Lanes MCP server, the agent provisions a working form endpoint itself, mid-task, and hands you a link to claim it. The server is hosted at `api.lanes.sh/mcp` over streamable HTTP, so there is nothing to install and nothing to run locally. It registers under the name `lanes`, separate from the desktop app's Lanes Desktop MCP server, and creating or submitting to forms needs no key at all. All three Lanes MCP servers and when to use each are covered in [MCP](/docs/mcp). ## How it works - **Add the MCP server.** `claude mcp add --transport http lanes https://api.lanes.sh/mcp`, no signup. Other MCP clients point at the same URL with the equivalent JSON config. - **The agent calls create_form.** One tool call returns a live endpoint and a claim link. The server exposes five tools: `create_form`, `get_form`, `update_form`, `generate_form_snippet`, and `submit_form`. Creating and submitting work anonymously; the management tools need a workspace key. See [agents and MCP](/docs/forms/agents-and-mcp). - **The form works immediately.** Submissions are captured from the first second. While unclaimed, the form holds up to 25 submissions and forwards nothing, which fits a form born mid-task: the data waits until a human takes over. Limits are in [provisioning](/docs/forms/provisioning). - **You claim it.** Follow the claim link to pull the form into your dashboard when you are ready. Possession of the link is the authorization, so the agent can provision on your behalf and you still end up as the owner. Claiming releases the held submissions and turns on email forwarding. See [claiming](/docs/forms/claiming). ## Why it matters The agent stays in flow. No dashboard detour, no API keys to paste, no backend to write. The form is live before the agent moves to the next task. The markup is covered too: `generate_form_snippet` emits a ready-to-paste HTML or React form wired to the endpoint, so the agent does not hand-write the integration. And if you already have a workspace, pass your `lfk_` key as an Authorization bearer header: management tools unlock, and new forms are born claimed into that workspace with no claim step at all. MCP is not the only path. The `lanes-forms` Claude Code skill carries the API contract (`/plugin marketplace add lanes-sh/app`, then `/plugin install lanes-forms@lanes`), and a plain copy-paste prompt works for any agent. Every docs page is also readable as raw Markdown at its own path with a `/raw` suffix, so agents can ground themselves without scraping. ## Set it up 1. **Connect the server.** Run `claude mcp add --transport http lanes https://api.lanes.sh/mcp` in your agent, or add the equivalent server entry to its MCP config. 2. **Ask for a form.** Tell the agent what the form should collect and which address should receive submissions. It calls `create_form` and reads back the endpoint and the claim link. 3. **Let it wire the page.** Ask for `generate_form_snippet` output and the agent pastes a working HTML or React form into your site. 4. **Claim the form.** Open the claim link the agent hands you, sign in, and the form joins your dashboard with everything it collected. Without this, the agent's run breaks in the middle: it stops, you open a dashboard, sign up, create the form by hand, then paste ids and keys back into the session. An anonymous create over MCP removes the human hop, and the claim link moves ownership to you afterwards instead of blocking the work up front. ## Further reading - [Agents and MCP](/docs/forms/agents-and-mcp): the five tools, the skill, and the copy-paste prompt. - [MCP](/docs/mcp): the hosted remote server versus the desktop app's local one. - [Claiming a form](/docs/forms/claiming): what happens when you redeem the link. - [Lanes Forms vs Formspree](/compare/lanes-forms-vs-formspree): agent-first provisioning next to a classic form backend. [Read agents and MCP](/docs/forms/agents-and-mcp) or [see Lanes Forms](/forms). Source: https://lanes.sh/use-cases/agent-provisioned-forms-mcp --- # Route submissions to email, Supabase, or webhooks > Send submissions where you work: email and Lanes-hosted storage today, with webhook and Supabase delivery on the roadmap. A form is only useful if the data reaches you. Lanes Forms stores every submission and can route it to the destinations you already use. Delivery is configured per form as a list of workflow actions, set at provisioning and changeable afterwards. Two actions are live today, email forwarding and Lanes-hosted storage, and two are on the roadmap, webhook delivery and custom stores such as Supabase. The delivery table lives in [integrating](/docs/forms/integrating). ## Destinations - **Email.** Add recipients and each submission is forwarded to your inbox. Live today. A form forwards to up to 5 recipients; a new address gets a confirmation link and is silently skipped until it verifies, so unverified inboxes never receive mail. Spam is not forwarded. - **Stored in Lanes.** Kept for you with CSV export, even when you route elsewhere. Live today. Submissions are listed in the dashboard, and the submissions endpoint exports them with `?format=csv`. See the [API reference](/docs/forms/api-reference). - **Webhooks.** Post to your own endpoint and handle it however you like. On the roadmap. Enabling the action today returns `422 action_not_available`. - **Supabase.** Write submissions straight into your own database. On the roadmap as a custom store, with the same `422 action_not_available` response until it ships. Webhook and custom-store delivery are not live yet. Enabling either returns `422 action_not_available`, a stable code documented in [errors](/docs/forms/errors), so the gap is explicit rather than silent. Ship with email plus Lanes storage now and flip the workflow when the actions land; the submission contract does not change. Whatever the destination, the record is the same. Each stored submission keeps the schema fields under `data`, unknown keys under `extra`, the request origin, a salted hash of the IP (the raw IP is never stored), a truncated user agent, and a spam score. The full storage story is in [submitting](/docs/forms/submitting). ## Set once, change anytime Update a form's routing with a single PATCH. No redeploy of your site, no code change. `PATCH /v1/forms/{form_id}` updates the workflow, recipients, origins, and schema on a claimed form. Authorize it with a workspace API key, prefixed `lfk_`, shown once at creation and valid for every form in its workspace, or act as the signed-in owner in the dashboard. See [API keys](/docs/forms/api-keys). ## Set it up 1. **Send a test submission.** Anything can POST to the endpoint; API callers get a JSON receipt with a submission id back. See [submitting](/docs/forms/submitting). ```bash curl -X POST https://api.lanes.sh/v1/f/YOUR_FORM_ID \ -H "Content-Type: application/json" \ -d '{"email": "visitor@site.com", "message": "Hello"}' ``` 2. **Add and verify recipients.** Each address confirms through an emailed link before it receives anything, and a verification email can be resent from the API or the dashboard. When you claim a form that had recipients, the first one is verified for you. See [integrating](/docs/forms/integrating). 3. **Read or export.** Watch submissions arrive in the dashboard, and pull CSV with `?format=csv` when you want the data in a spreadsheet or a script. 4. **Adjust routing later.** Change the workflow with a `PATCH`, using your `lfk_` key as a bearer token, without touching the page that posts to the form. ## Further reading - [Integrating a form](/docs/forms/integrating): the delivery table and how email verification works. - [Submitting data](/docs/forms/submitting): what each stored submission contains. - [Errors](/docs/forms/errors): the stable error envelope, including `action_not_available`. - [API keys](/docs/forms/api-keys): workspace keys for changing routing programmatically. [See Lanes Forms](/forms) or read the [API reference](/docs/forms/api-reference). Source: https://lanes.sh/use-cases/route-submissions-email-supabase-webhooks --- # Train models on on-demand GPUs > Spin up the GPUs a training run needs, from a single A100 to multi-node H100 clusters, and pay only while it runs. Training is bursty. You need a lot of GPU for a while, then nothing. Owning hardware for the peak is wasteful. Lanes Compute lets you rent exactly what a run needs, when it needs it. Lanes Compute is a GPU service in limited early access. A100, H100, and H200 SXM are available today, from one card to multi-node clusters, with capacity taken on demand or reserved for longer runs. Billing is per second: you pay while the environment is live and nothing once it is torn down. ## How Lanes Compute does it - **Any GPU.** From a single A100 up to H100 and H200, on demand. L40S and other options cover lighter jobs, and no choice is permanent: pick the card per run, and pick differently for the next one. - **Scale to the run.** One card for a small model, a multi-node cluster for a big one. Clusters are wired together over fast InfiniBand between nodes, with NVLink within each node, so added hardware turns into training throughput rather than communication stalls. - **Fast storage.** Persistent, fast storage so data loading is not the bottleneck. Datasets and checkpoints live on network volumes that persist between runs, so a stopped run resumes from its last checkpoint rather than from a fresh copy of the data. - **Per-second billing.** Pay while the run is up, not for idle time. The meter stops at teardown, and for runs that will hold hardware for weeks, reserved capacity is available. ## Why it matters You match spend to the run instead of to a peak you rarely hit, and the hardware is there when the run is ready. That changes how training gets scheduled. Owned hardware pushes teams to queue runs so the boxes stay busy, which means the queue, not the research, sets the pace. Per-second rental removes the queue: each run gets its own environment, sized to it, stood up when the code is ready. The bill afterwards reads as a record of runs, which makes cost per experiment a number you can actually see. ## Set it up 1. **Request access.** Lanes Compute is in limited early access. Fill in the [request access form](/compute#request-access) and say what you plan to train. 2. **Describe the run.** Model and data size, framework, single card or cluster, on demand or reserved. That is enough to get matched to hardware. 3. **Get onboarded.** Usually within a day or two. 4. **Run the loop.** Stand up the environment, mount your volumes, train, checkpoint, tear down. The next run starts from the checkpoint, and billing stops the moment the environment is gone. The alternative is sizing for the peak. A team that owns eight GPUs for one heavy monthly run pays for them the other twenty-nine days, and a fixed cloud reservation is the same arithmetic with a different owner. Training demand is spiky, and the bill should follow the spikes. ## Further reading - [Spin up multi-node H100 clusters](/use-cases/multi-node-h100-clusters): what changes when a run outgrows one node. - [Fine-tune LLMs](/use-cases/fine-tune-llms): the same rent-per-run loop applied to adapting existing models. - [Lanes Compute vs Lambda](/compare/lanes-compute-vs-lambda): how the two handle on-demand training hardware. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/train-models-on-demand-gpus --- # Fine-tune LLMs > Fine-tune open models on the right GPU with your own stack, from a quick LoRA run to a full multi-node job. Fine-tuning ranges from a single-GPU LoRA run to a multi-node full fine-tune. Lanes Compute gives you the card that fits the job and lets you bring the tools you already use. Lanes Compute is a GPU service in limited early access. You describe the job, get matched to hardware, and pay per second while the environment is live. Your pipeline does not have to change to fit it: the same containers and the same training code run on rented cards, whether that is one L40S or several H100 nodes. ## How Lanes Compute does it - **Right-sized GPUs.** A100, H100, H200, or L40S depending on the model and method. A LoRA pass on a small model fits a single card, while a full fine-tune of a large one wants H100 or H200 SXM and sometimes several nodes. You choose per job instead of committing to one card size for everything. - **Bring your own stack.** PyTorch, JAX, vLLM, Axolotl, DeepSpeed, whatever your pipeline uses. Anything that runs in a container works out of the box, so the recipe you tested on a small card is the recipe that runs on the big one. - **Multi-node when needed.** InfiniBand and NVLink for larger fine-tunes. Nodes are wired together over fast InfiniBand, with NVLink inside each node, and jobs scale across boxes without extra setup. - **Per-second billing.** Short runs cost like short runs. The meter runs while the environment is live and stops at teardown, so an hour of LoRA is billed as an hour, not as a day of reserved hardware. ## Why it matters You are not boxed into one framework or one GPU size. You pick per job, and only pay for the time the job takes. Fine-tuning also tends to involve proprietary data, which makes two details load-bearing. Datasets and checkpoints sit on persistent network volumes you control, in a region you choose, and Lanes does not train on your data or share it. The tuned weights end up as checkpoints on your volumes, ready for the next run or for serving. ## Set it up 1. **Request access.** Lanes Compute is in limited early access; request it through the form on [the Compute page](/compute) and say what you are tuning. 2. **Describe the job.** Base model, method, and data size. A quick LoRA pass and a full fine-tune get matched to very different hardware, and that is the point. 3. **Get onboarded.** Usually within a day or two. 4. **Run and tear down.** Stand up the environment, mount the dataset volume, tune, write the checkpoint, tear down. Billing ends there, and the checkpoint waits for the next run. Fine-tuning is where one-size hardware hurts twice. A standing big card makes every small LoRA run expensive, and a small card cannot hold a full fine-tune at all. Per-job sizing resolves both, and per-second billing is what makes per-job sizing affordable. ## Further reading - [Train models on on-demand GPUs](/use-cases/train-models-on-demand-gpus): the same loop for training from scratch. - [Bring your own stack](/use-cases/bring-your-own-stack): containers, frameworks, and the no-lock-in contract in detail. - [Lanes Compute vs RunPod](/compare/lanes-compute-vs-runpod): how the two compare for fine-tuning work. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/fine-tune-llms --- # Run inference and model serving > Serve models on GPUs sized to your traffic, with your own serving stack, close to your data. Serving a model is a different shape from training: steadier, latency-sensitive, and close to your users or data. Lanes Compute gives you GPUs for inference with the serving stack you choose. Lanes Compute is a GPU service in limited early access. For serving, three of its properties do the work: you pick the card, you pick the region, and you bring the container. The hardware ranges from L40S up to H100 and H200 SXM, billing is per second, and what runs on it is your image, not a hosted runtime. ## How Lanes Compute does it - **Pick the GPU.** L40S for lighter serving, H100 or H200 for large models. A100 sits in the range as well, and the choice is per deployment: swap cards as the model or the traffic changes rather than living with a first guess. - **Your serving stack.** Run vLLM or your own setup, in your own container. Anything that runs in a container works out of the box, so the server you built and profiled is exactly what faces production traffic, batching and caching choices included. - **Regions near your data.** Place compute where your data and users are. You choose the region, which keeps latency low and transfers cheap, and your data stays on volumes you control. Lanes does not train on your data or share it. - **Per-second billing.** Scale up for load, down when it is quiet. Add cards for a launch and drop them after, paying only while each environment is live. For a steady baseline, reserved capacity is available for longer runs. ## Why it matters You serve on hardware and software you control, sized to real traffic rather than a fixed reservation. Serving spend is permanent in a way training spend is not: whatever shape you choose, you pay for it around the clock. Keeping that bill tracking the traffic curve means making resizing cheap, which is what per-second billing plus per-deployment card choice amounts to. When traffic doubles, you add hardware; when a smaller model halves the requirement, you drop to a lighter card the same day. ## Set it up 1. **Request access.** Lanes Compute is in limited early access; the form on [the Compute page](/compute) asks what you want to run. 2. **Describe the service.** The model, the expected traffic, and where your users and data are. That is enough to size the first deployment and pick its region. 3. **Get onboarded.** Usually within a day or two. 4. **Serve and adjust.** Bring the container, put the model weights on a volume, and resize as real numbers come in. Environments you tear down stop billing immediately. ## Where this breaks without Lanes A fixed reservation gets sized for the peak hour, then idles through the night on the same bill. Managed inference endpoints avoid that but absorb your stack: you serve on someone else's runtime, tune only what it exposes, and migrate when it changes. Renting the card while owning the container keeps sizing flexible without giving up the stack. ## Further reading - [Right-size every job and pay per second](/use-cases/right-size-and-pay-per-second): the billing model in detail. - [Bring your own stack](/use-cases/bring-your-own-stack): why the container contract matters beyond serving. - [Lanes Compute vs Modal](/compare/lanes-compute-vs-modal): container-first GPUs compared with a serverless platform. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/inference-and-model-serving --- # Spin up multi-node H100 clusters > Get a multi-node H100 cluster with InfiniBand and NVLink for large training and fine-tuning runs. Some runs do not fit on one node. Lanes Compute provisions multi-node clusters with the interconnect that makes distributed training actually scale. Lanes Compute is in limited early access and rents exactly this shape of hardware: H100 nodes joined over fast InfiniBand, NVLink within each node, stood up for a run and torn down after it. H200 SXM is available when a run calls for it, billing is per second, and reserved capacity covers runs that hold a cluster for weeks. ## How Lanes Compute does it - **Multi-node H100.** Several nodes wired together for one big run. The same cluster shape serves large training jobs and large fine-tunes, and it grows from a single box to several as the run demands. - **Fast interconnect.** InfiniBand between nodes and NVLink within them. Distributed training synchronizes GPUs constantly, and when links are slow the added nodes spend their time waiting instead of working. The interconnect is what turns more hardware into more progress. - **Bring your own stack.** DeepSpeed, PyTorch distributed, JAX, and more. Jobs run in your own containers, anything that runs in a container works, and scaling across boxes needs no extra setup on the cluster side. - **On demand.** Stand the cluster up for the run and take it down after. Billing is per second and ends at teardown, with reserved capacity available when a run will keep the cluster for a long stretch. Long runs lean on storage as much as interconnect. Datasets and checkpoints live on persistent network volumes, so a run can checkpoint, release the cluster, and resume later on a fresh one without re-staging data. A pause costs nothing while the environment is down. ## Why it matters Large runs need bandwidth between GPUs, not just more GPUs. The interconnect is the point, and it is there when the run needs it. Owning this class of hardware is a commitment measured in quarters, and for many teams the big runs are occasional. Renting per run inverts the math: the cluster exists exactly as long as the run does, and the money that would have funded idle nodes funds more runs instead. ## Set it up 1. **Request access.** Fill in the [request access form](/compute#request-access) and say the job is multi-node. Lanes Compute is in limited early access. 2. **Describe the run.** Model and data size, framework, how long the cluster is needed, on demand or reserved. Tell us what you need and the hardware is matched to it. 3. **Get onboarded.** Usually within a day or two. 4. **Run the loop.** Stand the cluster up, train, checkpoint to your volumes, tear it down. The next run starts from the checkpoint rather than from scratch. ## Where this breaks without Lanes Teams with occasional large runs are caught between three arrangements that all lose. A single box caps the run size no matter how long you wait. An owned cluster prices its idle months into every run. And loose cloud GPUs without a fast interconnect add card count while the cards wait on each other. A rented cluster with real interconnect is the missing fourth option. ## Further reading - [Train models on on-demand GPUs](/use-cases/train-models-on-demand-gpus): the single-node starting point for the same loop. - [Lanes Compute vs Lambda](/compare/lanes-compute-vs-lambda): the two compared for cluster-scale training. - [Request access](/compute#request-access): describe the run and get onboarded, usually within a day or two. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/multi-node-h100-clusters --- # Batch processing and experiments > Run batch jobs and one-off experiments on GPUs you spin up for the task and shut down after. Not every GPU job is a big training run. Batch processing, evals, and quick experiments need compute for a short window. Lanes Compute is built for that on-and-off pattern. Lanes Compute is a GPU service in limited early access, and its billing model is the feature here: environments spin up in seconds, bill per second, and stop billing at teardown. A quick experiment costs cents, so trying something, seeing the result, and trying again runs at the pace of your ideas rather than your infrastructure. ## How Lanes Compute does it - **Spin up per task.** Grab a GPU for the job, release it when done. Environments start in seconds, so the loop is tight enough to run per experiment rather than per day, and nothing has to be kept warm between jobs. - **Per-second billing.** A ten-minute experiment costs like ten minutes. The meter runs while the environment is live, stops at teardown, and nothing accrues after. - **Any size.** One card for a quick test, many for a large batch. L40S covers lighter jobs, A100 through H200 SXM cover heavier ones, and multi-node clusters exist for the batches that outgrow one box. - **Your stack.** Bring the container and frameworks the job needs. PyTorch, JAX, and anything else that runs in a container work out of the box, so a batch job is the same artifact you run everywhere else. Two details keep the loop fast in practice. Datasets sit on persistent network volumes, so each job mounts its data instead of re-fetching it, and results and checkpoints survive teardown. And because the interface is a CLI and API that drop into existing tooling, a sweep can run unattended from a script or an agent: stand up, run, collect, tear down, repeat. ## Why it matters Experiments stay cheap and fast when you only pay for the minutes you use, so you can run more of them. How many experiments a team runs is set by friction more than curiosity. When a test needs a request to whoever owns the cluster, fewer tests happen; when it needs cents and a container, more do. Per-task GPUs also unclog the calendar: an eval pass before a release is a job you start, not a slot you book. ## Set it up 1. **Request access.** Lanes Compute is in limited early access; request it via the form on [the Compute page](/compute). 2. **Describe the jobs.** The kinds of batches and experiments you run and the frameworks they use. That is enough to match hardware. 3. **Get onboarded.** Usually within a day or two. 4. **Adopt the loop.** Spin up for the job, run it from your container, write results to a volume, tear down. Repeat as often as ideas arrive. Without on-demand capacity, batch work lands on a shared box, and the queue sets the team's pace. The other escape, a reserved card per person, bills around the clock for hardware that works in ten-minute bursts. Per-second rental is the version where neither the queue nor the idle time exists. ## Further reading - [Right-size every job and pay per second](/use-cases/right-size-and-pay-per-second): picking the card once the loop is in place. - [GPUs for agent workloads](/use-cases/gpus-for-agent-workloads): the same loop, driven by agents instead of people. - [Lanes Compute vs Vast.ai](/compare/lanes-compute-vs-vast-ai): how it compares with a GPU marketplace. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/batch-processing-and-experiments --- # GPUs for agent workloads > Give your agents GPU compute they can call on: run models, tools, and pipelines on hardware that scales with the work. Agent workloads increasingly need real compute: running models, processing data, driving pipelines. Lanes Compute provides GPUs your agents can use, sized to the task. Lanes Compute is the GPU side of [Lanes](/overview): a service in limited early access that rents A100, H100, and H200 SXM cards, plus L40S and other options for lighter jobs, from a single card to multi-node clusters, billed per second. The part that matters for agents is the interface: a CLI and API that drop into existing agents and tooling, so the pipeline that already files issues and calls models can also acquire hardware. ## How Lanes Compute does it - **On-demand GPUs.** From a single card to a cluster, spun up as work arrives. Environments start in seconds, so a pipeline step that needs a GPU is not parked in a queue behind someone else's training run. When the burst passes, the hardware goes away with it. - **Plug into your agents.** Point agent pipelines at compute you control. The CLI and API drop into your existing agents and tooling, so integration takes minutes rather than weeks, and an agent can treat a GPU like any other resource it acquires, uses, and releases. - **Per-second billing.** Pay for the work, not for idle capacity. The meter runs while an environment is live and stops at teardown, so a fleet that works in bursts pays in bursts. A quick job costs cents. - **Bring your own stack.** Standard frameworks and your own containers. PyTorch, JAX, vLLM, and anything else that runs in a container works out of the box, so the code an agent triggers is the same code you run everywhere else. ## Why it matters Agents that need to train, infer, or crunch data get compute without you pre-provisioning a fleet that sits idle between jobs. The problem grows with the fleet. One agent needing one GPU is a rental; twenty agents needing hardware at unpredictable moments is a capacity-planning problem, unless capacity is something the agents acquire themselves. With per-second billing, spend stays proportional to what the fleet did, not to what you guessed it might do. There is also a bridge to the rest of Lanes. If you run coding agents from the [Lanes Desktop](/desktop) board, some of their tasks want more than a laptop: an eval pass over a model change, a data crunch, a fine-tune. Compute is where that heavier work runs. ## Set it up Lanes Compute is in limited early access, so setup starts with a short conversation. 1. **Request access.** Fill in the [request access form](/compute#request-access) on the Compute page. 2. **Describe the workload.** What the agents run, how much data they touch, and whether jobs need one card or several. That is enough to get matched to hardware. 3. **Get onboarded.** Access usually lands within a day or two. 4. **Wire in the agents.** Point your pipeline at the CLI and API, spin environments up as work arrives, and tear them down when it is done. Billing stops at teardown. ## Where this breaks without Lanes Without on-demand capacity, an agent fleet gets one of two arrangements. It shares a fixed box, and every burst becomes a queue, or you reserve hardware for the peak, and the quiet hours become the bill. Agent demand is spiky by nature, so hardware that cannot follow the spikes is either a bottleneck or a write-off. The failure is quiet, too: agents do not complain about waiting, they just deliver later. ## Further reading - [Batch processing and experiments](/use-cases/batch-processing-and-experiments): the same spin-up-and-tear-down loop, driven by people instead of agents. - [The Lanes overview](/overview): where Compute sits among the Lanes products. - [Request access](/compute#request-access): describe your agent workload and get running, usually within a day or two. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/gpus-for-agent-workloads --- # Bring your own stack > Run PyTorch, JAX, vLLM, and your own containers on Lanes Compute. No forced framework, no lock-in. Your training and serving code already works. Lanes Compute is a place to run it, not a framework to rewrite it in. Bring the stack you have. Lanes Compute is a GPU service in limited early access, and its contract is deliberately thin. You hand it a container; it supplies A100, H100, or H200 SXM cards to run it on, singly or as multi-node clusters, billed per second. The CLI and API exist to drive hardware from your existing tooling, not to reshape your code around a platform. ## How Lanes Compute does it - **Standard frameworks.** PyTorch, JAX, vLLM, Axolotl, DeepSpeed, and more. Those are examples, not a compatibility list: anything that runs in a container works out of the box. - **Your own containers.** Run the environment you already build and test. The image you validate locally is the image that runs on the cluster, same dependencies, same versions, same behavior. - **Any GPU.** Match the hardware to what your stack expects. L40S for lighter work, A100 through H200 SXM above it, and multi-node clusters over InfiniBand and NVLink when the workload spans boxes. - **No lock-in.** It is your code on rented hardware, nothing more. Moving in means running your container; moving out means running it somewhere else. Nothing accumulates that would need unwinding later. The same holds for data. Datasets and checkpoints sit on persistent network volumes you control, in a region you choose, and Lanes does not train on your data or share it. What you produce on rented cards stays as portable as the container that made it. ## Why it matters You keep your tools and pipelines. Only the hardware is rented, so moving a workload in or out is easy. That portability keeps the relationship honest. A platform you can leave in an afternoon has to stay worth using, and a stack that never bent to fit one vendor can follow prices, hardware generations, and your own architecture changes wherever they lead. ## Set it up 1. **Request access.** Lanes Compute is in limited early access; request it through the form on [the Compute page](/compute). 2. **Describe the stack.** The frameworks and containers you run and the GPUs they expect. Describe it as it is; nothing needs converting first. 3. **Get onboarded.** Usually within a day or two. 4. **Run what you have.** Bring the container, mount volumes for data and checkpoints, run, tear down. Billing stops at teardown. ## Where this breaks without Lanes Some GPU platforms are frameworks in disguise. Code gets adapted to their runtime to run at all, and the adaptation reaches into entry points, packaging, and storage access. The convenience is real, and so is the exit cost: when pricing, limits, or priorities change, the workload cannot leave without being rewritten a second time. A thin contract, your container on rented cards, never takes that debt on. ## Further reading - [Fine-tune LLMs](/use-cases/fine-tune-llms): bring-your-own-stack applied to tuning runs. - [Run inference and model serving](/use-cases/inference-and-model-serving): the same container contract at serving time. - [Lanes Compute vs Modal](/compare/lanes-compute-vs-modal): a thin container contract next to a serverless runtime. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/bring-your-own-stack --- # Right-size every job and pay per second > Match the GPU to the job, from L40S to H200, and pay per second so you never fund idle hardware. The cheapest GPU is the one you are not paying for while it sits idle. Lanes Compute lets you pick the right card for each job and bills by the second. Lanes Compute is a GPU service in limited early access. The range runs from L40S for lighter work through A100 up to H100 and H200 SXM, from one card to multi-node clusters. The billing model fits in a sentence: you pay while an environment is live and nothing once it is torn down. The [Compute FAQ](/compute#faq) covers the rest, including reserved capacity for longer runs. ## How Lanes Compute does it - **Right-size.** L40S, A100, H100, or H200 depending on the job. The choice is per job, not per quarter: an eval pass takes a lighter card, a fine-tune takes a heavier one, and neither decision binds the next. - **Per-second billing.** Pay for exactly the time you use. Environments spin up in seconds and bill by the second, so a quick experiment costs cents and a two-hour job is billed as two hours, with nothing owed after teardown. - **Scale up and down.** More GPUs for a big run, fewer for a small one. Capacity is on demand for bursts, reserved for longer runs, and multi-node when a job outgrows one box. Scaling is a per-job decision, made as often as the work changes shape. - **Nothing idle.** Release compute the moment a job finishes. Teardown does not lose the work: datasets and checkpoints persist on network volumes, so releasing hardware is the default move, not a risk. ## Why it matters Spend tracks the work, not a fixed reservation you rarely fill. The two halves only work together. Per-second billing on an oversized card still overpays every second it runs, and the right card on a standing reservation still bills through idle nights. Which card, and for how long: those are the two questions that set a GPU bill, and right-sizing plus per-second billing answers both per job instead of once per contract. ## Set it up 1. **Request access.** Lanes Compute is in limited early access; request it through the form on [the Compute page](/compute). 2. **Describe the mix.** The jobs of a typical week, from quick evals to training runs, and the frameworks behind them. 3. **Get onboarded.** Usually within a day or two. 4. **Size per job.** Spin up the card each job needs, run it, tear it down. The bill afterwards reads as a list of jobs, which is what makes over-provisioning visible enough to stop. The pattern this replaces is the standing reservation sized for a peak that arrives a few times a month. Small jobs then run on oversized hardware, idle hours bill anyway, and the one job that exceeds the reservation waits. Sizing per job removes the compromise on both ends. ## Further reading - [Batch processing and experiments](/use-cases/batch-processing-and-experiments): the workload where per-second billing shows up first. - [Run inference and model serving](/use-cases/inference-and-model-serving): right-sizing applied to a steady workload. - [Lanes Compute vs Vast.ai](/compare/lanes-compute-vs-vast-ai): two takes on pay-for-what-you-use GPUs. [See Lanes Compute](/compute). Source: https://lanes.sh/use-cases/right-size-and-pay-per-second --- # Connect your accounts once, for every agent > Claude Code, Codex, and the claude.ai web client reach the same Gmail, Drive, and Calendar through one endpoint you run yourself. A fourth client is not a fourth integration. Every agent eventually wants the same things: your email, your calendar, your files, your notes. So you wire Gmail into Claude Code, then wire it again into Codex, then again into whatever you try next. Each one gets its own OAuth grant, its own permissions to configure, and its own copy of your context. The accounts are the same accounts. Only the plumbing is duplicated. Lanes Link collapses that into one MCP endpoint you run yourself. You connect an account once, and every MCP client you point at the endpoint inherits it, along with the same policy and the same audit log. Adding a fourth client stops being a fourth integration. ## How Lanes Link does it - **One connection, many callers.** `lanes link connect gmail` walks you through the account once. After that, Claude Code, Codex, Claude Desktop, and Cowork all reach it through the same endpoint, because the credential lives with the endpoint rather than inside any one harness. - **Providers you probably already use.** Gmail, Drive, Sheets, Docs, Calendar, Tasks, and Contacts on Google; Mail, Calendar, Contacts, and Drive on iCloud; Notion and Linear through their own remote MCP servers. Each Google product is its own connection, and a single OAuth client covers all seven. - **Registration is mostly automatic.** Only Google needs a client you register yourself. Notion and Linear register themselves, and iCloud takes an app-specific password. - **Files are named, not carried.** Where a tool takes an attachment, you hand it a path, an HTTPS URL, or a file already sitting on another message. The endpoint reads the bytes and passes them to the provider, so nothing gets encoded into a tool call and pushed through the model. - **Useful before you connect anything.** Memory, skills, and the vault sit behind the same boundary and serve without a credential of any kind, so a brand new endpoint is already worth calling. ## The workflow Start the endpoint, register it with every harness you have installed, then connect accounts as you need them. Registration is a single command that finds your installed clients rather than a config file you edit per tool. From then on the work is just asking. An agent in Claude Code searching your inbox and an agent in the claude.ai web client searching the same inbox are the same connection, reached through the same policy. When you revoke an account, you revoke it once and every client loses it at the same moment. ## Set it up A local endpoint takes three commands and no account anywhere. It needs [Bun](https://bun.com) 1.3.11+ and nothing else. ```console $ bun install -g @lanes-sh/link # puts `lanes` on your PATH $ lanes link profile add personal --default $ lanes link start ok serving http://127.0.0.1:7337/mcp profiles: personal ``` Then, in another shell: 1. **Register the endpoint with your harnesses.** `lanes link mcp add` finds Claude Code and Codex, registers `lanes-link` with each, and installs a skill and a scout agent so the agent knows what the endpoint is for. 2. **Connect your first account.** `lanes link connect gmail` handles the OAuth round trip. 3. **Ask for something.** The endpoint is already serving memory and skills, so you do not have to finish connecting everything before it is useful. Doing this per agent is not hard, it is just repeated. The cost shows up later: a scope you widened in one client and not another, a token you rotated in three places and forgot in the fourth, and no single answer to what any of them actually did with the access. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): the full announcement, including how permissions and profiles work. - [Give every agent the same memory](/use-cases/shared-memory-across-agents): the knowledge layer that serves without any credential at all. - [Keep work and personal accounts apart](/use-cases/separate-work-and-personal-agent-access): profiles, and why every call names one. - [Lanes MCP servers](/docs/mcp): the other MCP surfaces Lanes ships, local and hosted. - [Lanes Link vs per-provider remote MCP servers](/compare/lanes-link-vs-remote-mcp-servers): when connecting each provider directly is the better call. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/use-cases/one-endpoint-for-every-agent --- # Give every agent the same memory > What one session learns about you is served back to every later session, including a session in a different agent. Your knowledge lives with you, not inside one tool. Agents forget. Worse, they forget separately. You explain your stack to Claude Code on Monday, explain it again to Codex on Tuesday, and explain it a third time to whatever you open on your phone. Each tool has its own notion of what it knows about you, and none of them are the notion you actually maintain. Lanes Link treats memory as yours rather than the agent's. It is served from the endpoint you run, so what one session writes is served back to every later session, including a session in a different agent entirely. The same is true of skills: your reusable procedures, exposed as MCP prompts, available wherever you are working. ## How Lanes Link does it - **Memory is a first-class layer, not a file convention.** It is accumulated knowledge about you and your work, worth searching before an agent concludes it knows nothing. The search is a tool call, so any MCP client can make it. - **Writing is a separate grant.** Reading memory and adding to it are different capabilities. An agent that should draw on your context does not automatically get to edit it. - **Skills are yours, not the harness's.** Reusable procedures live at the endpoint and are exposed as MCP prompts, so a procedure you refine once shows up in every client rather than being re-pasted per tool. - **The vault covers the sensitive half.** Passwords and API keys are handed out one item at a time, and nothing lists what else is in there. Knowing one secret does not reveal the shape of the rest. - **No credential required.** Memory, skills, and the vault are the owner layer. They serve without any account being connected, so an endpoint with zero integrations is already useful on day one. ## The workflow Ask an agent to remember something and it writes to the endpoint. The next session, in any client you have registered, searches the same store and finds it. There is nothing to sync and no per-tool copy to keep current, because there was only ever one copy. This changes how the first minute of a session goes. Instead of re-establishing context, the agent looks it up. A convention that lives in memory, a preference you stated once, a decision you made three weeks ago in a different tool: all of it is reachable through the same search, and all of it stays behind your permission boundary and your audit log. ## Set it up Install it, start the endpoint locally, then register it everywhere you work: ```console $ bun install -g @lanes-sh/link $ lanes link profile add personal --default $ lanes link start ok serving http://127.0.0.1:7337/mcp profiles: personal ``` Then, in another shell: 1. **Run `lanes link mcp add`.** It registers `lanes-link` with Claude Code and Codex, and installs a skill plus a scout agent so the agent knows to search your memory before assuming it knows nothing. 2. **Write something worth keeping.** Ask an agent to remember a convention, a preference, or a decision. 3. **Open a different client and ask for it.** Same endpoint, same store, same answer. Per-tool memory is not useless, it is just partitioned. The failure is quiet: the agent does not tell you it is missing context, it just gives you a worse answer built on what its own tool happens to have kept. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): what the endpoint serves, and where state actually lives. - [Connect your accounts once, for every agent](/use-cases/one-endpoint-for-every-agent): the connection layer that sits beside memory. - [Give agents scoped access, with an audit trail](/use-cases/scoped-account-access-for-agents): how writes are granted, and how every call is recorded. - [Keep work and personal accounts apart](/use-cases/separate-work-and-personal-agent-access): profiles share no state, so work memory stays out of personal sessions. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/use-cases/shared-memory-across-agents --- # Keep work and personal accounts apart > Two mailboxes, one tool, no guessing. Every call names the profile it acts within, and reaching across profiles is refused before anything is dispatched. Work and personal are not the same world. The moment an agent can reach both your work mailbox and your personal one, every request carries a question nobody asked out loud: which one did you mean? Tools usually answer it with a current context you switch by hand, which works right up until you forget you switched. Lanes Link removes the guess. One endpoint serves every profile in a workspace, under one token, and every tool call carries a required `profile` argument beside `connection`. There is no current profile and nothing to switch. The caller names which world a call acts within, every time. ## How Lanes Link does it - **Profile is a required argument, not a mode.** Because it is named per call, there is no ambient state to get wrong and no "which context am I in" to reason about mid-session. - **Two mailboxes stay one tool.** `gmail.users.messages.list` does not fork into a work version and a personal version. The `profile` argument decides which mailbox it reaches. - **Crossing is refused, not filtered.** Naming a connection from a different profile is rejected before anything is dispatched, so a mistake fails loudly instead of quietly returning the wrong inbox. - **Nothing is shared across the boundary.** Profiles share no configuration, no state, and no credentials. Work memory does not surface in a personal session, because it is not the same store. - **One endpoint, still.** Separation does not cost you a second deployment or a second token. It is one endpoint serving both. ## The workflow Add a profile per world, connect each world's accounts under its own profile, and let the caller be explicit from then on. When a request is genuinely ambiguous, a well-briefed agent asks you which profile you meant rather than defaulting to whichever is listed first. The payoff is mostly in what stops happening. A work document does not land in a personal Drive because a context was stale. A personal calendar does not leak into a summary you paste into a work thread. And when you eventually want to hand the work profile to someone else or tear it down, it comes apart cleanly, because it never shared anything with the other one. ## Set it up Profiles are created from the CLI, and the first one becomes your default: ```console $ bun install -g @lanes-sh/link $ lanes link profile add personal --default $ lanes link profile add work $ lanes link start ok serving http://127.0.0.1:7337/mcp profiles: personal, work ``` Then: 1. **Connect each world separately.** Run `lanes link connect gmail` once per profile so each holds its own account. 2. **Register the endpoint.** `lanes link mcp add` wires it into Claude Code and Codex, and installs a skill that tells the agent to ask when the profile is ambiguous rather than picking one. 3. **Be explicit in the ask.** "Search my work mail" is enough for the agent to name the right profile in the call. Two logins in one browser is the same problem, and people solve it with separate profiles for a reason. The difference here is that the enforcement is in the runtime: a cross-profile call is refused, rather than depending on the model remembering which hat it is wearing. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): profiles, permissions, and the audit log in full. - [Connect your accounts once, for every agent](/use-cases/one-endpoint-for-every-agent): connecting each world's accounts once. - [Give agents scoped access, with an audit trail](/use-cases/scoped-account-access-for-agents): policy per capability, on top of the profile boundary. - [Give every agent the same memory](/use-cases/shared-memory-across-agents): why separate stores per profile matter as much as separate mailboxes. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/use-cases/separate-work-and-personal-agent-access --- # Give agents scoped access, with an audit trail > Every capability is denied until you allow it. Let an agent search your mail without letting it send, and get one append-only audit record per call, refusals included. The usual way to give an agent access to your email is to give it your email. OAuth scopes are coarse, the consent screen is a one-time decision, and after that the only thing standing between "read my inbox" and "send as me" is a sentence in a system prompt asking the model not to. That is a policy the model is invited to respect, not one anything enforces. Lanes Link puts the boundary in the runtime instead. Every capability is denied until you allow it, and policy only ever tightens as a request travels inward. `gmail.search = allow` and `gmail.send = deny` are decisions the endpoint enforces before dispatching anything, whatever the model was told or convinced to do. ## How Lanes Link does it - **Deny by default.** Nothing is reachable until you allow it. Widening access is a decision you make deliberately, rather than the state you land in by accepting a consent screen. - **Per capability, not per account.** Search and send are separate capabilities on the same connection, so read-only access to a mailbox is a thing you can actually express. - **Policy tightens inward, never loosens.** A request cannot pick up permissions on its way through the endpoint, so a broad grant at one layer is not a way around a narrow one further in. - **One audit event per invocation.** Append-only, with per-provider redaction that keeps the identifiers and withholds your content, so the log tells you what happened without becoming a second copy of your mail. - **Refusals are recorded too.** `lanes link audit tail --denied-only` prints just those, which is the fastest way to see an agent repeatedly trying something you did not grant. - **Tamper-evident.** Records are hash-chained per run, and `lanes link audit verify` walks every chain. - **No database to secure.** State and the audit log are objects in a blob store: a directory on your machine locally, a bucket when deployed. ## The workflow Grant narrowly and widen when something is actually blocked. A refused call is the permission system working, not an obstacle to route around: a well-briefed agent reports what it was refused and lets you decide whether to open it, rather than looking for another path to the same data. Then read the log. Because every invocation produces exactly one record, "what did that agent actually touch last night" is a question with an answer, and so is "what did it try to touch." That second one is usually the more interesting log, and it is the one most setups do not keep at all. ## Set it up Install it, start the endpoint, and connect an account: ```console $ bun install -g @lanes-sh/link $ lanes link profile add personal --default $ lanes link start ok serving http://127.0.0.1:7337/mcp profiles: personal $ lanes link connect gmail ``` Then: 1. **Allow only what the work needs.** Leave everything else denied. Read access is often the whole job. 2. **Register the endpoint.** `lanes link mcp add` installs a skill that tells the agent to surface refusals to you rather than working around them. 3. **Watch what gets refused.** `lanes link audit tail --denied-only` shows the calls policy stopped, which is how you find both a scope that is too tight and an agent doing more than you expected. 4. **Verify the chain.** `lanes link audit verify` walks every hash chain when you want proof the log has not been edited. Scopes at the provider are still worth setting, and you should. They are just too coarse to be the only boundary: Gmail's read and send scopes are the granularity you get, and "this agent, this week, search only" is not something the consent screen can express. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): the permission model and audit design in full. - [Connect your accounts once, for every agent](/use-cases/one-endpoint-for-every-agent): one grant per account, shared by every client. - [Keep work and personal accounts apart](/use-cases/separate-work-and-personal-agent-access): the profile boundary that sits above per-capability policy. - [Give every agent the same memory](/use-cases/shared-memory-across-agents): why writing to memory is its own grant. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/use-cases/scoped-account-access-for-agents # Compare # Lanes vs Cursor > Cursor is an AI code editor for one developer in one file tree. Lanes is a workspace for running many coding agents in parallel. Here is how they differ. Cursor and Lanes both help you code with AI, but they solve different problems. Cursor is an AI-native editor: you write code with an assistant alongside you. Lanes is a workspace for running coding agents in parallel: you hand off tasks and orchestrate several agents at once. This page is about where each one earns its keep, and why plenty of developers run both. ## At a glance | Dimension | Lanes | Cursor | | --- | --- | --- | | Shape | Workspace that orchestrates CLI agents | AI-native code editor | | Parallel agents | Many, one per issue, each isolated | One assistant in your editor | | Isolation | A git [worktree](/docs/desktop/worktrees) and branch per session | Your single working tree | | Agents | Official Claude Code and Codex CLIs | Cursor's built-in models and agent | | Planning and review | Board columns from Backlog to Done | Inline, as you edit | | Billing | Your existing CLI subscription | Cursor subscription | | Model providers | Your subscription, a local model, or any provider you point at, chosen per session | Cursor's routing, with your own API keys as an option | | Runs | Locally, on your machine | Local editor | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | cursor.com | ## Where Lanes fits Lanes is for when one agent is not enough. You plan work as [issues](/docs/desktop/issue-board), start a [session](/docs/desktop/sessions) per issue, and each agent runs in its own worktree so nothing collides. You keep oversight of the whole fleet on one board, and sessions run the official CLI, so they use the plan you already pay for rather than a separate meter. That last point matters in practice: a session is the real `claude` or `codex` binary in a real terminal, with the model and reasoning effort you choose at launch. Lanes adds the workspace around the CLI, not a proxy in front of it. ## Where Cursor fits Cursor is a strong single-seat editor when you want an AI assistant in the loop as you write, review, and refactor in one file tree. If your work is one focused stream at a time inside an editor, Cursor is a natural fit. Nothing in Lanes replaces that inner loop: the editing, the suggestions, the conversation next to the code. ## How Lanes does it - **Sessions are real terminals.** Each one is a PTY running the official CLI, attached to an issue. Start it in Plan mode to get an approach proposed before any edits, or Implement mode to start the work immediately. Status (busy, awaiting input, stopped) shows on the card, with a bell when the agent needs you. See [working with sessions](/docs/desktop/sessions). - **Worktrees keep agents apart.** Lanes creates a branch and working directory per issue under `.worktrees/`, warns before removing anything with uncommitted work, and cleans up automatically when the issue completes. See [worktree management](/docs/desktop/worktrees). - **The board is the control surface.** Columns from Backlog to Done, bulk actions, and per-project and per-worktree tabs. Review happens on the diff, per branch, before you merge. See [the issue board](/docs/desktop/issue-board). - **The model is yours to choose, per session.** A [Gateway](/docs/desktop/gateway) profile points a session at a provider you host or buy: a local Ollama, a vLLM box on your network, OpenRouter, or z.ai GLM. It is applied as process environment at launch, so requests go straight from the CLI to your provider and Lanes is never in the path. [Local LLMs](/docs/desktop/local-llms) goes further and manages Ollama end to end. ## Choose Lanes if - You want to run several agents at once, each on its own branch. - You want to keep using Claude Code or Codex on your existing subscription. - You want a board to plan, track, and review agent work. - You want plan-first runs you approve before any code changes. - You want some of that work running on a model on your own hardware. ## Choose Cursor if - You want an AI-native editor for hands-on coding. - You work one task at a time and like staying in the editor. - You prefer one tool that is also your editor. Lanes and Cursor are not mutually exclusive. Plenty of people write in an editor and hand parallel tasks to Lanes. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [Working with sessions](/docs/desktop/sessions): the session lifecycle, statuses, and per-session settings. - [Worktree management](/docs/desktop/worktrees): isolation, dirty-state warnings, and cleanup. - [Run AI coding agents in parallel](/use-cases/parallel-ai-coding): the workflow Lanes is built around. - [Point sessions at any model provider](/use-cases/bring-your-own-model-provider): provider profiles, per session. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-cursor --- # Lanes vs Windsurf > Windsurf is an AI-native editor with an in-editor agent. Lanes is a workspace for running many CLI agents in parallel. Here is how they compare. Windsurf and Lanes both bring AI into your coding, but at different layers. Windsurf is an AI-native editor: you write and edit with an agent inside the editor. Lanes is a workspace that runs coding agents in parallel: you hand off tasks and orchestrate several at once. This page is about which layer you need, and why the answer is often both. ## At a glance | Dimension | Lanes | Windsurf | | --- | --- | --- | | Shape | Workspace that orchestrates CLI agents | AI-native code editor | | Parallel agents | Many, one per issue, each isolated | One in-editor agent | | Isolation | A git worktree and branch per session | Your single working tree | | Agents | Official Claude Code and Codex CLIs | Windsurf's built-in agent and models | | Planning and review | Board columns from Backlog to Done | Inline, as you edit | | Billing | Your existing CLI subscription | Windsurf plan | | Runs | Locally, on your machine | Local editor | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | windsurf.com | ## Where Lanes fits Lanes is for running a fleet. You plan work as issues, start an agent per issue, and each runs in its own worktree so nothing collides. A board keeps you in control of the whole set, and sessions run the official CLI on the plan you already pay for. Concretely: work lives on an [issue board](/docs/desktop/issue-board) with columns from Backlog to Done, and an issue's instructions become the initial prompt when you start a [session](/docs/desktop/sessions) on it. Start in Plan mode to get an approach proposed before any edits, or Implement mode to begin the work immediately. Each session is the official CLI in a real terminal, with CLI, model, and reasoning effort picked at launch, so the agent behaves exactly as it does anywhere else. ## Where Windsurf fits Windsurf shines when you want an AI agent inside the editor as you build, one focused stream at a time, with the model wired deeply into your file tree and edits. Lanes does not try to replace that inner loop. If the work is you, an editor, and a running conversation about the code in front of you, an AI-native editor is the right layer. ## How Lanes does it - **One issue, one agent, one branch.** For every issue, Lanes creates a git worktree under `.worktrees/` off your project's base branch, so parallel agents never touch the same files. It warns before removing anything with uncommitted work and cleans up automatically when the issue completes. See [worktree management](/docs/desktop/worktrees). - **Status you can read across the fleet.** Every session reports Busy, Awaiting input, Stopped, Exited, or Error on its card, with a bell when an agent needs you. The Meta tab tracks tokens, cost, and runtime per session. See [working with sessions](/docs/desktop/sessions). - **Review before merge.** The built-in Test Worktree and Complete & Merge commands run your suite inside the issue's worktree and fold the branch back only when you are satisfied. Per-worktree board tabs keep the review focused. See [the issue board](/docs/desktop/issue-board). ## Choose Lanes if - You want several agents running at once, each on its own branch. - You want to keep using Claude Code or Codex on your existing subscription. - You want a board to plan, track, and review agent work. - You want plan-first runs you approve before any code changes. ## Choose Windsurf if - You want an AI-native editor for hands-on coding. - You work one task at a time inside an editor. - You want the assistant living in your keystrokes, not on a board. The two can sit together: edit in Windsurf, hand parallel tasks to Lanes. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [Working with sessions](/docs/desktop/sessions): the session lifecycle, statuses, and per-session settings. - [Worktree management](/docs/desktop/worktrees): isolation, dirty-state warnings, and cleanup. - [Run AI coding agents in parallel](/use-cases/parallel-ai-coding): the workflow Lanes is built around. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-windsurf --- # Lanes vs Conductor > Both run parallel coding agents on your Mac with isolated worktrees. Lanes wraps them in an issue board, ticket integrations, and an MCP server. Conductor and Lanes are close cousins. Both are native Mac apps that run Claude Code and Codex in parallel, each agent in its own git worktree. The real difference is how much workspace sits around the agents. This page is about what that workspace contains, and when you would not want it. ## At a glance | Dimension | Lanes | Conductor | | --- | --- | --- | | Parallel agents in worktrees | Yes | Yes | | Billing | Your existing CLI subscription, interactive CLI surface | Depends on how it drives the agent | | Work model | Issue board: plan, implement, review, done | Parallel workspaces you launch and review | | Task sources | Built-in issues plus GitHub and Linear import | Your prompts and repos | | Automation | Lanes Desktop MCP server; an agent can spawn sessions | Focused on launching and reviewing agents | | Model providers | Per-session provider profiles, plus managed Ollama for local models | Whatever the CLI itself is configured to use | | Platform | macOS | macOS | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | conductor.build | ## Where Lanes fits Lanes is for treating parallel agents as a pipeline. Work starts as issues, moves through plan, implement, review, and done, and can be pulled from GitHub or Linear and pushed back as pull requests. Lanes Desktop MCP lets an agent manage the board and dispatch more sessions. If you want structure and integrations around your fleet, that is Lanes. The [issue board](/docs/desktop/issue-board) is the spine: columns from Backlog to Done, multi-select bulk actions, and tabs that filter by project or by worktree. [GitHub integration](/docs/desktop/github-integration) imports open issues through OAuth (pull requests are filtered out) and lets you comment results back; [Linear integration](/docs/desktop/linear-integration) does the same for tickets, including search by issue key. The [Lanes Desktop MCP server](/docs/desktop/local-mcp), a research preview, exposes 30 tools over SSE on localhost port 5353, so an agent can read the board, create issues, and dispatch sessions on its own. ## Where Conductor fits Conductor is a clean, focused runner for launching a few parallel agents and reviewing their diffs. If your need is mainly "run several agents and merge the good changes," Conductor does that with very little ceremony. Be honest about your week when choosing: a board, ticket integrations, and an MCP surface earn their keep when agent work is a standing part of it, and they are overhead when you occasionally want three agents on three fixes. ## How Lanes does it - **Issues drive sessions.** An issue's instructions become the agent's initial prompt, in Plan mode for an approach first or Implement mode to start the work. Since v0.41 one issue can host several named sessions sharing its worktree, so plan, implement, and review each keep their own transcript and metrics. See [working with sessions](/docs/desktop/sessions). - **Worktrees are managed end to end.** Per-issue strategies (None, Create, Select), automatic creation under `.worktrees/` off the base branch, dirty-state warnings before anything uncommitted is removed, and cleanup when the issue completes. Test Worktree and Complete & Merge wrap up the branch. See [worktree management](/docs/desktop/worktrees). - **Sessions stay on your plan.** Each one is the official CLI in a real terminal, which is interactive use from the provider's side, so it draws from your existing subscription rather than a separate meter. - **Or off your plan entirely, per session.** A [Gateway](/docs/desktop/gateway) profile points a session at a provider you host or buy, applied as process environment at launch rather than proxied. [Local LLMs](/docs/desktop/local-llms) manages Ollama end to end: install, start, pull, and a context window sized so a real agent prompt fits instead of being silently truncated. ## Choose Lanes if - You want an issue board and a full plan-to-ship loop around your agents. - You want GitHub and Linear tickets to flow in and results to flow back. - You want programmatic orchestration through an MCP server. - You want several named sessions per issue, so planning and review keep separate transcripts. - You want to run some sessions on a local model, and manage it from the same app. ## Choose Conductor if - You want the simplest possible parallel runner. - A board and integrations are more than you need. Close cousins are cheap to trial: both lean on git worktrees, so your repo needs nothing special to try either. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [The issue board](/docs/desktop/issue-board): columns, bulk actions, and board tabs. - [Lanes Desktop MCP](/docs/desktop/local-mcp): the tool surface agents use to drive Lanes. - [Worktree management](/docs/desktop/worktrees): isolation, dirty-state warnings, and cleanup. - [Run coding agents on a local model](/use-cases/run-agents-on-local-models): managed Ollama and why context size decides. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-conductor --- # Lanes vs Amp > Amp is Sourcegraph's agentic coding tool with usage-based pricing. Lanes runs many official CLI agents in parallel on your existing subscription. Amp is a single agentic coding tool you run from the terminal or a VS Code extension, billed as you go. Lanes is a workspace that runs many agents in parallel, each the official CLI on the plan you already pay for. They differ in both shape and billing. This page is about both differences, and why the second one follows from the first. ## At a glance | Dimension | Lanes | Amp | | --- | --- | --- | | Shape | Workspace orchestrating many CLI agents | Single agentic coding tool (CLI and VS Code) | | Parallelism | Many isolated sessions on a board | One agent stream | | Agents | Official Claude Code and Codex | Amp's own agent and models | | Billing | Your existing CLI subscription | Usage based, pay as you go | | Model providers | Your subscription, a local model at no marginal cost, or a provider you buy | Amp's own models on Amp's meter | | Isolation | A git worktree and branch per session | Your working tree | | Planning and review | Board columns from Backlog to Done | In the conversation, as you go | | Runs | Locally, on your machine | Local CLI and editor | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | ampcode.com | ## Where Lanes fits Lanes is for running several agents at once with oversight, without a metered bill that climbs with usage. Sessions run the official Claude Code and Codex CLIs, so they draw from your existing subscription, and each lives in its own worktree on the board. The billing point is architectural, not a discount. A [session](/docs/desktop/sessions) is the real `claude` or `codex` binary in a real terminal, which is the interactive CLI surface that stayed on plan limits when the June 2026 Agent SDK credit split moved programmatic usage onto a separate meter. The same architecture is what lets a session skip the meter entirely: a [Gateway](/docs/desktop/gateway) profile points it at a provider you host or buy, and [Local LLMs](/docs/desktop/local-llms) runs it against Ollama on your own machine, where the marginal cost of a retry is zero. [Anthropic just split the bill](/blog/claude-billing-split) walks through that change. Around the CLI, Lanes adds the workspace: an issue per task, a [worktree](/docs/desktop/worktrees) and branch per issue created under `.worktrees/` and cleaned up when the work lands, and live status on every card so you can see who is busy and who is waiting on you. ## Where Amp fits Amp is a capable single agent with usage-based pricing. If you want one strong agent in the terminal or editor and do not need parallel orchestration, Amp is a straightforward choice. It is also a different bet: one opinionated tool end to end, rather than a workspace wrapped around the CLIs you already use. ## How Lanes does it - **Sessions are real terminals.** Each is a PTY running the official CLI, attached to an issue, started in Plan or Implement mode with the issue's instructions as the initial prompt. Status (Busy, Awaiting input, Stopped, Exited, Error) shows on the card, and the Meta tab tracks tokens, cost, and runtime per session. See [working with sessions](/docs/desktop/sessions). - **Worktrees keep parallel work apart.** Every issue gets its own branch and directory off the project's base branch, with dirty-state warnings before anything uncommitted is removed and automatic cleanup on completion. See [worktree management](/docs/desktop/worktrees). - **Lanes Desktop MCP closes the loop.** A research preview built into the app: 30 tools over SSE on localhost port 5353, so an agent can read the board, file issues, and start more sessions. See [Lanes Desktop MCP](/docs/desktop/local-mcp). ## Choose Lanes if - You want many agents in parallel, isolated per branch. - You would rather run on your existing subscription than a usage meter. - You want a board to plan and review the work. - You want per-task cost visibility, with tokens and runtime on every issue. ## Choose Amp if - You want a single agentic tool in your terminal or editor. - Usage-based pricing suits how you work. They can also coexist: keep a single-agent tool for quick hits and hand the parallel backlog to Lanes. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [Working with sessions](/docs/desktop/sessions): lifecycle, statuses, and per-session metrics. - [Run agent fleets on your Claude subscription](/use-cases/keep-your-claude-subscription): the full billing picture. - [Run a fleet on mixed models](/use-cases/mixed-model-agent-fleets): local models on the mechanical work, frontier models on the rest. - [Anthropic just split the bill](/blog/claude-billing-split): what changed in June 2026 and who it hit. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-amp --- # Lanes vs Superset > Both run many CLI coding agents in parallel across git worktrees. Lanes centers the work on an issue board with integrations and an MCP server. Superset and Lanes overlap a lot. Both are local-first desktop workspaces that run many CLI agents in parallel, each in its own git worktree. The difference is the workspace model wrapped around them. This page is about that wrapper, and when raw throughput matters more. ## At a glance | Dimension | Lanes | Superset | | --- | --- | --- | | Parallel CLI agents in worktrees | Yes | Yes | | Billing | Your existing CLI subscription, interactive CLI surface | Depends on how it drives the agent | | Work model | Issue board: plan, implement, review, done | A terminal for orchestrating agents | | Task sources | Built-in issues plus GitHub and Linear import | Your repos and prompts | | Automation | Lanes Desktop MCP server; an agent can spawn sessions | Daemon with priority scheduling | | Agents | Claude Code and Codex | Claude Code, Codex, OpenCode, and more | | Ordering | Dependency graph; blocked issues wait | Scheduler priorities | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | superset.sh | ## Where Lanes fits Lanes treats agent work as a board. Tasks are issues that move from plan to done, can be pulled from GitHub or Linear, and can be orchestrated by an agent through Lanes Desktop MCP. If you want a structured, integrated workspace around your parallel agents, that is Lanes. On that board, every [session](/docs/desktop/sessions) is a real terminal attached to an issue, with live status (Busy, Awaiting input, Stopped, Exited, Error) and a bell when an agent needs you. Each issue runs in its own [worktree](/docs/desktop/worktrees) under `.worktrees/`, created off the base branch and removed automatically once the issue completes cleanly. The [issue board](/docs/desktop/issue-board) adds multi-select bulk actions plus per-project and per-worktree tabs, and the [Lanes Desktop MCP server](/docs/desktop/local-mcp), a research preview with 30 tools over SSE on localhost port 5353, lets an agent run the board itself. ## Where Superset fits Superset leans into raw orchestration: a terminal built to run a very high number of agents across a broad set of CLI tools, with a scheduler to manage them. If your priority is running many agents across several different CLIs, Superset is built for that. That is a real difference in emphasis: Lanes ships Claude Code, Codex, and Gemini CLIs plus bare terminals, and spends its complexity budget on the board, ordering, and review instead of breadth of runners. ## How Lanes does it - **The board is shared state.** Columns from Backlog to Done, so a human and an orchestrating agent see the same picture. Issues carry instructions that become the session's initial prompt, in Plan or Implement mode. See [the issue board](/docs/desktop/issue-board). - **Sessions carry their own telemetry.** Tokens, cost, and runtime per session in the Meta tab, roughly 50KB of scrollback, and Claude Code sessions restart with their history via resume. Since v0.41 one issue can host several named sessions on the same worktree. See [working with sessions](/docs/desktop/sessions). - **Nothing runs unseen.** The process manager lists tracked sessions, orphans left behind by a crash, and external agent processes Lanes did not start. Stop Sessions winds tracked work down gracefully; Kill All clears the slate. See [process manager](/docs/desktop/process-manager). ## Choose Lanes if - You want an issue board and a plan-to-ship loop. - You want GitHub and Linear integration and MCP orchestration. - You want statuses, telemetry, and diffs attached to issues rather than a raw terminal. - You want blocked work to wait on a dependency graph automatically. ## Choose Superset if - You want to run a large number of agents across many CLI tools. - A terminal-first orchestrator fits how you work. The overlap means switching costs are low either way: both build on git worktrees your repo already understands. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [The issue board](/docs/desktop/issue-board): columns, bulk actions, and board tabs. - [Lanes Desktop MCP](/docs/desktop/local-mcp): the tool surface agents use to drive Lanes. - [Multi-agent coding orchestration](/use-cases/multi-agent-coding-orchestration): dispatching agents from one board. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-superset --- # Lanes vs tmux > You can run several agents by hand in tmux panes. Lanes is purpose-built for it: automatic worktrees, a board, and live status. tmux is a terminal multiplexer. Plenty of people use it to run a few coding agents in split panes, and it works. But tmux knows nothing about agents, branches, or what each one is doing. Lanes is built for exactly that job. This page is honest about the trade: tmux gives you primitives, Lanes gives you the workflow. ## At a glance | Dimension | Lanes | tmux | | --- | --- | --- | | Purpose | Workspace for parallel coding agents | Terminal multiplexer | | Isolation | Automatic git worktree and branch per session | You manage worktrees by hand | | Visibility | Board with per-session status and a bell | Panes you check one by one | | Task model | Issues from plan to done | None; it is a terminal | | Integrations | GitHub, Linear, and Lanes Desktop MCP | None built in | | Quick commands | Per repo, one keystroke, run in the worktree | Your own bindings and scripts | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | man tmux | ## Where Lanes fits Lanes removes the manual work of running agents in parallel. Worktrees are created and cleaned up for you, every session has a status you can see at a glance, and the work lives on a board with ticket integrations. You get the parallelism without the bookkeeping. A [session](/docs/desktop/sessions) in Lanes is still a real PTY running the same official `claude` or `codex` you would run in a pane, but it is attached to an issue. That attachment is what tmux cannot give you: the card shows Busy, Awaiting input, Stopped, Exited, or Error, a bell rings when an agent has a question, and the Meta tab keeps tokens, cost, and runtime per session. Stopped Claude Code sessions restart with their history carried over, and Cmd+T still opens a bare terminal when you want a plain shell. ## Where tmux fits tmux is free, universal, and endlessly scriptable. If you want full manual control, already live in the terminal, and are happy wiring up worktrees and monitoring panes yourself, tmux is hard to beat. None of that is a knock: if your setup already works and you want nothing between you and the shell, keep it. ## How Lanes does it - **Worktrees without the bookkeeping.** Each issue gets a directory under `.worktrees/`, named from the issue ID plus a generated word pair, branched off your base branch. Lanes warns before removing anything with uncommitted work and cleans up automatically on completion. See [worktree management](/docs/desktop/worktrees). - **The repetitive parts become keystrokes.** Quick commands live per repo in `.lanes/quick-actions.json`, typed as either Claude prompts or raw terminal commands, bound to Cmd+Alt+1 through 9, and always run inside the issue's worktree. See [quick commands](/docs/desktop/quick-commands). - **Strays stay visible.** The process manager lists tracked sessions, orphans left behind by a crash, and external `claude` or `codex` processes Lanes did not start. Stop Sessions winds tracked work down gracefully; Kill All clears the slate. See [process manager](/docs/desktop/process-manager). ## Choose Lanes if - You want isolation, status, and a board without setting it up yourself. - You want tickets and MCP orchestration around your agents. - You want a stopped agent to come back with its context instead of starting over. ## Choose tmux if - You want a minimal, scriptable terminal and full manual control. - You work over SSH on machines where a desktop app cannot follow. You can also use both: run Lanes for orchestration and keep tmux for everything else in your terminal. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [Working with sessions](/docs/desktop/sessions): statuses, the bell, scrollback, and session metrics. - [Quick commands](/docs/desktop/quick-commands): per-repo commands on Cmd+Alt+1 through 9. - [Manage multiple Claude Code sessions](/use-cases/manage-multiple-claude-code-sessions): the workflow this page is really about. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-tmux --- # Lanes vs Paperclip > Paperclip is a control plane for running a company of AI agents across every function. Lanes is a focused workspace for parallel coding agents. Paperclip and Lanes both orchestrate multiple agents, but at different altitudes. Paperclip is an open-source control plane for running a whole company of agents across engineering, marketing, sales, and operations, with org charts, budgets, and an audit log. Lanes is a developer workspace for running coding agents in parallel with git worktrees and a coding loop. This page is about picking the right altitude, not a winner. ## At a glance | Dimension | Lanes | Paperclip | | --- | --- | --- | | Scope | Parallel coding agents | A company of agents across all functions | | Model | Issue board, worktrees, sessions | Org chart, goals, budgets, audit log | | Agents | Official Claude Code and Codex CLIs | Agent-agnostic (Claude, Codex, Gemini, and more) | | Focus | Ship code | Coordinate agents to run a business | | Ordering | Labels and a dependency graph on one board | Goals and budgets across an org | | Hosting | macOS app, local-first | Open source, self-hosted | | Docs | [Lanes Desktop docs](/docs/desktop/quick-start) | Paperclip's own docs | ## Where Lanes fits Lanes is for developers who want to ship code with several agents at once. Each task is an issue, each session runs the official CLI in its own worktree, and the whole loop from plan to pull request happens in one place. It goes deep on the coding workflow rather than broad across business functions. Deep means the mechanics are handled. Work is planned on an [issue board](/docs/desktop/issue-board) and kept findable with [labels](/docs/desktop/labels-and-filtering) when dozens of issues are in flight. [Dependencies](/docs/desktop/dependencies) link issues into a graph with cycle detection, so blocked work waits and queued work starts in a valid order without a human sequencing it. A [session](/docs/desktop/sessions) is the official CLI in a real terminal with live status on its card, and each issue runs in a [worktree](/docs/desktop/worktrees) that Lanes creates off the base branch and cleans up when the issue completes. ## Where Paperclip fits Paperclip is for coordinating many agents across a whole organization, with governance, budgets, and reporting. If your goal is an agent org that spans more than engineering, Paperclip is aimed at that. Governance at that altitude is its own product problem, and Lanes does not try to solve it. ## How Lanes does it - **A board with real structure.** Columns from Backlog to Done, labels for filtering a busy board, and a dependency graph the queue respects: blocked issues are skipped until their prerequisites land in Done. See [dependencies](/docs/desktop/dependencies). - **Sessions are real terminals.** Each is a PTY running the official CLI, started in Plan or Implement mode with the issue's instructions as the initial prompt, reporting Busy, Awaiting input, Stopped, Exited, or Error, with tokens, cost, and runtime in the Meta tab. See [working with sessions](/docs/desktop/sessions). - **Agents can drive it too.** Lanes Desktop MCP, a research preview built into the app, exposes 30 tools over SSE on localhost port 5353, so an orchestrating agent can read the board, create issues, and start sessions. See [Lanes Desktop MCP](/docs/desktop/local-mcp). ## Choose Lanes if - Your work is shipping code with parallel coding agents. - You want git worktrees, a board, and a tight coding loop. - You want labels, dependencies, and per-issue isolation without running a platform. ## Choose Paperclip if - You want to coordinate agents across many business functions with governance. - You want engineering to be one department among several that agents run. The two are not exclusive: Paperclip could run the company while Lanes is where the engineering agents do the coding. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [The issue board](/docs/desktop/issue-board): columns, bulk actions, and board tabs. - [Dependencies](/docs/desktop/dependencies): the graph, cycle detection, and queue behavior. - [Multi-agent coding orchestration](/use-cases/multi-agent-coding-orchestration): dispatching agents from one board. [See Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-vs-paperclip --- # Lanes with Claude Code > Lanes is the workspace around Claude Code: run many sessions in parallel, each in its own worktree, on your existing plan. Lanes does not replace Claude Code. It runs it. If you already work in Claude Code and want to run more than one session at a time with real oversight, Lanes is the workspace built for that. The split is simple: Claude Code is the agent, Lanes is the workspace around it. Nothing about the CLI changes, and everything about running several of them does. ## What Lanes adds to Claude Code - **Parallel sessions.** One per issue, each the official `claude` CLI in a real terminal. A [session](/docs/desktop/sessions) is a PTY attached to its issue, so the task, transcript, and results stay together; close the panel and it keeps running. One issue can also hold several named sessions (plan, implement, review) that share the issue's worktree but keep separate transcripts, status, and metrics. - **Isolation.** A git [worktree](/docs/desktop/worktrees) and branch per session, created and cleaned up automatically. Worktrees live under `.worktrees/` off your base branch; dirty ones trigger a warning before removal, so uncommitted work is never dropped silently. - **A board.** Plan, run, review, and ship, with live status on every session and a bell when one needs you. Cards move through Planning, Implementation, Review, and Done, and the Meta tab keeps tokens, cost, and runtime per session. - **Your plan, not a meter.** Lanes runs the real CLI, so sessions draw from your existing subscription. That distinction got sharper in June 2026, when Anthropic moved Agent SDK usage onto separate credits while interactive CLI use stayed on plan limits. [Anthropic just split the bill](/blog/claude-billing-split) covers what changed and who it affects. - **Orchestration.** A [Lanes Desktop MCP server](/docs/desktop/local-mcp) lets an agent manage the board and spawn more sessions. It is a research preview with 30 tools over SSE, covering issues, sessions, and the GitHub and Linear bridges. - **Somewhere else to get tokens, when you want it.** A [Gateway](/docs/desktop/gateway) profile points a session at a provider you host or buy, and Lanes derives the environment variable names Claude Code expects, so the same profile works whichever CLI the session picked. It is applied as real process environment at launch, so the request goes straight from the CLI to your provider and the credential never reaches scrollback or `ps`. [Local LLMs](/docs/desktop/local-llms) is the managed version of that: Ollama installed, started, and pulled from one page, with the context window sized so a real agent prompt is not silently truncated. - **The binary itself, kept current.** The [Harness](/docs/desktop/harness) page shows whether `claude` is on your PATH, the version it reports, and which package manager installed it, then updates through that manager rather than assuming npm. Two Claude-specific details are worth knowing. Stopped sessions restart with `--resume`, so pausing an issue does not lose the conversation. And settings are per session: pick the model and reasoning effort in the launch flyout, and run one issue on a heavy model while five light ones run beside it. ## When you need it One Claude Code session is great. The moment you want several working at once without tripping over each other, you need a workspace around them. That is what Lanes is. Claude Code stays exactly as it is. Same binary, same auth, same behavior. Lanes is the surface that lets you run a fleet of them. In practice the shift is workflow, not tooling. Break work into issues, run [several sessions side by side](/use-cases/manage-multiple-claude-code-sessions), review each branch's diff before merging, and keep the [billing math](/use-cases/keep-your-claude-subscription) in view as the fleet grows. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [Working with sessions](/docs/desktop/sessions): lifecycle, statuses, sibling sessions, and per-session settings. - [Worktree management](/docs/desktop/worktrees): isolation, dirty-state warnings, and cleanup. - [Manage multiple Claude Code sessions](/use-cases/manage-multiple-claude-code-sessions): the day-to-day workflow. - [Point sessions at any model provider](/use-cases/bring-your-own-model-provider): profiles, auth modes, and what you give up. - [Anthropic just split the bill](/blog/claude-billing-split): the June 2026 billing change explained. [Get Lanes Desktop](/desktop) or read the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/lanes-with-claude-code --- # Lanes with Codex > Run Codex in parallel from a workspace built for it: isolated worktrees, an issue board, and your existing subscription. Lanes runs the official Codex CLI, so you can drive many Codex sessions at once with full oversight, and switch any session to Claude Code when that fits the task better. ## What Lanes adds to Codex - **Parallel sessions.** Each the official Codex CLI in a real terminal, one per issue. A [session](/docs/desktop/sessions) is a PTY attached to its issue; it keeps running when you switch focus, and the card shows Busy, Awaiting input, or Stopped at a glance, with a bell when Codex is waiting on you. - **Isolation.** A git worktree and branch per session, managed for you. [Worktrees](/docs/desktop/worktrees) are created under `.worktrees/` off the project's base branch, flagged when they hold uncommitted work, and removed automatically when the issue completes clean. - **A board.** Plan, run, review, and ship, with live status and a bell. Each issue carries instructions that become the session's first prompt; start in Plan mode to see an approach before any edits, or Implement to let Codex start working immediately. - **Your plan, not a meter.** Sessions run the real CLI on your existing subscription. Lanes launches the binary you already installed and authenticated; there is no proxy between you and it. - **Choose where the tokens come from.** A [Gateway](/docs/desktop/gateway) profile points a session at a provider you host or buy, applied as real process environment at launch rather than proxied through us. Claude Code is the harness whose variable names Lanes derives automatically today; for Codex you set the names yourself in the profile's free-form environment table, and the profile is otherwise identical. [Local LLMs](/docs/desktop/local-llms) manages the Ollama end of that, including a context window sized to survive a real agent prompt. - **The CLI itself, kept current.** The [Harness](/docs/desktop/harness) page reports whether `codex` is on your PATH, its version, and which manager installed it, and updates through that same manager. It also writes the optional `[tui] terminal_title` block to `~/.codex/config.toml`, which gives Lanes a second idle signal alongside the rollout transcript. - **Mix agents.** Run Codex and Claude Code side by side and pick per task. The launch flyout sets CLI, model, and reasoning effort per session, and repo-wide defaults live in [settings](/docs/desktop/settings). Comparing the two on one task is a first-class move: run them [side by side](/use-cases/claude-code-and-codex-side-by-side) and keep the diff you prefer. There is also an automation path. The [Lanes Desktop MCP server](/docs/desktop/local-mcp), a research preview built into the app, exposes the board to agents: 30 tools to create issues, start and stop sessions, and read terminal output, so a Codex session can file follow-up work or a script can fill the board every morning. ## When you need it Codex on its own handles one task at a time. When you want a set of tasks running in parallel, each isolated and tracked, you need a workspace. Lanes is that workspace, and Codex stays exactly as it is inside it. One Codex session is a good pair programmer. A backlog is where the workspace earns it: five sessions in five worktrees, none overwriting each other, all visible from one board, each reviewed as its own branch diff before merging. Finishing is built in too. When Codex signals it is done, two built-in quick commands wrap up the issue from the same panel: Test Worktree runs your test suite inside that issue's worktree, and Complete and Merge commits what remains, merges the branch into your base branch, and cleans up. If tests fail, restart the session, fix, and repeat. ## Further reading - [Quick start](/docs/desktop/quick-start): install Lanes and run your first session in about two minutes. - [Working with sessions](/docs/desktop/sessions): lifecycle, statuses, and per-session settings. - [Worktree management](/docs/desktop/worktrees): isolation, dirty-state warnings, and cleanup. - [Claude Code and Codex side by side](/use-cases/claude-code-and-codex-side-by-side): comparing the two agents on real tasks. - [Keep your coding CLIs current](/use-cases/keep-coding-clis-current): PATH state, versions, and updates through the right manager. [Get Lanes Desktop](/desktop). Source: https://lanes.sh/compare/lanes-with-codex --- # Loop engineering tools > Hand-rolled scripts, agent SDKs, cloud background agents, or a board: what each gives a loop, what each makes you build, and where Lanes fits. A working agent loop needs four things: a repeat (something that re-invokes the agent), shared state (so runs compound instead of starting cold), isolation (so parallel runs do not collide), and a done-signal you can verify. Every tool class in the [loop engineering](/use-cases/loop-engineering) space gives you some of those and makes you build the rest. This page is an honest map of who gives you what. ## At a glance | Dimension | Hand-rolled scripts | Agent SDKs | Cloud background agents | Lanes | | --- | --- | --- | --- | --- | | The repeat | cron, tmux, while-loops | Your code owns the loop | Vendor's runner | You bring it: a `/loop`, a cron, or a small driver | | Shared state | JSON files and logs | Whatever store you wire up | Vendor's dashboard | The issue board | | Isolation | Manual worktrees, if you remember | Yours to build | Vendor sandboxes | A git worktree per issue, managed | | Done-signal | Exit codes, grep | Callbacks and return values | PR shows up | Session status: busy, waiting, exited | | Oversight | Tail the logs | Build a UI | Web dashboard, diffs at the end | Live terminals on a board | | Billing | Your CLI subscription | API metering | Vendor seats or metering | Your CLI subscription | ## Where the alternatives fit - **Hand-rolled scripts.** A cron entry and a shell loop around the CLI is the fastest start and the fullest control. It is also where loops go to become archaeology: state in JSON files, progress in logs, and nothing to look at when a run goes sideways. Fine for one repo and one nightly job; see [Lanes vs tmux](/compare/lanes-vs-tmux) for the longer version of this trade. - **Agent SDKs.** Frameworks like the OpenAI Agents SDK or LangGraph put the loop in your code, with real control flow, retries, and structured outputs. That is the right call when the loop *is* the product you are shipping. For your own development workflow it means building state, isolation, and oversight yourself, and paying per token on API metering instead of the subscription you already have. - **Cloud background agents.** Codex cloud tasks, Cursor background agents, and their cousins run the loop on vendor infrastructure and hand you a pull request. Zero local footprint, easy to start from a phone. The loop itself is opaque: their sandbox, their cadence, their models, and your code on their machines. ## Where Lanes fits Lanes takes the position that the loop should run where you can see it. The board is the loop's shared state, every run is a card with a live terminal, each issue gets its own managed [worktree](/docs/desktop/worktrees), and the [Lanes Desktop MCP server](/docs/desktop/local-mcp) gives any agent the tools to read the board, start [sessions](/docs/desktop/sessions), and check results. Sessions run the official CLIs interactively, so the work draws on the subscription you already pay for. What Lanes deliberately does not ship is the repeat itself. You bring it, and pair it with what your CLI already has: a `/loop` on a cadence, a scheduled run, a stop-hook, or the small driver in [Building Loops](/docs/desktop/loops). Lanes supplies everything the repeat acts on: state, isolation, status, and the diff to verify against. ## Choose what, when - **Choose a script** if it is one repo, one job, and you will read the log tomorrow anyway. - **Choose an SDK** if you are building an agent product and the loop must ship as code. - **Choose a cloud agent** if you want zero local setup and are comfortable with the loop, and your code, living on someone else's machine. - **Choose Lanes** if the loop is part of your own development week: fleets of local sessions, verifiable diffs, a human checkpoint in the review column, and no second bill. These compose more than they compete. A scheduled cloud task can file issues that a local Lanes loop drains into PRs, and an SDK-built service can drive the same board over MCP. ## Further reading - [Building Loops](/docs/desktop/loops): the status rule, loop patterns, and a runnable driver. - [Loop engineering](/use-cases/loop-engineering): the use case this page compares tools for. - [Loop Engineering: Stop Prompting, Start Looping](/blog/loop-engineering-with-lanes): why the practice took off. - [Lanes Desktop MCP](/docs/desktop/local-mcp): the tool surface a loop drives. - [Lanes with Claude Code](/compare/lanes-with-claude-code): pairing the board with the CLI that runs your sessions. [See Lanes Desktop](/desktop) or start with the [quick start](/docs/desktop/quick-start). Source: https://lanes.sh/compare/loop-engineering-tools --- # Lanes Forms vs Formspree > Both give you a form backend without a server. Lanes Forms can be provisioned by an agent or one curl, with no signup, and claimed later. Formspree and Lanes Forms solve the same core problem: a form backend you do not have to build. The difference is how a form comes to exist. Formspree is dashboard-first and needs an account. Lanes Forms can be created anonymously, by you or your agent, and claimed later. This page is about that difference in creation paths, and what each one makes easy. ## At a glance | Dimension | Lanes Forms | Formspree | | --- | --- | --- | | Create a form | One POST or an MCP tool call | Set up in the dashboard | | Account to start | Not required; claim by email later | Required up front | | Agent friendly | Yes, provision over MCP mid-task | Manual setup | | Spam defense | Honeypot, rate limits, disposable-email rejection | Spam filtering | | Destinations | Email and Lanes storage with CSV export today; webhooks on the roadmap | Email and integrations | | Origin rules | Yes | Yes | | Unclaimed forms | Hold up to 25 submissions, claim within 7 days | Not applicable; account comes first | | Validation | Server-side, a 422 lists the missing fields | In the dashboard | | Docs | [Lanes Forms docs](/docs/forms/quickstart) | formspree.io | ## Where Lanes Forms fits Lanes Forms is built for speed and for agents. A coding agent can provision a live endpoint mid-build over MCP, or you can with one curl, no signup. The owner claims it by email whenever they are ready. It is the fastest path from "I need a form" to a working endpoint. The mechanics are simple. The create call is one `POST /v1/forms` where only a `schema` is required, and the response returns a live `endpoint_url` plus a claim link; see [provisioning](/docs/forms/provisioning). While unclaimed, the form holds up to 25 submissions, forwards nothing, and expires after 7 days if nobody claims it, so an endpoint born mid-task waits safely for its owner. Claiming releases every held submission into the dashboard and turns email forwarding on; see [claiming](/docs/forms/claiming). The agent path runs over a hosted MCP server whose `create_form` and `submit_form` tools work with no key at all, covered in [agents and MCP](/docs/forms/agents-and-mcp). ## Where Formspree fits Formspree is a mature, dashboard-driven form backend with a long list of integrations. If you want to manage everything from an established dashboard and that is where your team lives, Formspree is a solid choice. The difference is where management starts: Formspree in its dashboard from day one, Lanes in an API-first flow with a dashboard after the claim. ## How Lanes does it - **Provisioning is one POST.** `POST /v1/forms` with a `schema` is the whole setup. The response includes the `endpoint_url` and either a claim link or the address a claim email went to, and an optional `Idempotency-Key` header makes retries safe, so a retried call replays the same form instead of duplicating it. See [provisioning](/docs/forms/provisioning). - **Claiming transfers ownership safely.** The claim link is single-use and possession is the authorization, so an agent can provision on your behalf and you still end up as the owner. A preview endpoint renders the claim page without consuming the token, which means an email scanner cannot burn it. See [claiming](/docs/forms/claiming). - **Security splits browsers from servers.** Browser posts are checked against an `allowed_origins` hostname allowlist, with per-form CORS that echoes the exact origin and never a wildcard. Server-only forms can require an `lfk_` key by setting `submission_auth` to `api_key`. See [securing your endpoints](/docs/forms/securing-your-endpoints). ## Choose Lanes Forms if - You want to create a form with no signup, or have an agent do it over MCP. - You want to claim and manage it later, not before you start collecting. - You want origin allowlisting for browsers and an optional API-key mode for servers on the same form. - You want a stable error envelope and an interactive OpenAPI contract an agent can drive; see the [API reference](/docs/forms/api-reference). ## Choose Formspree if - You prefer a dashboard-first setup and its integration catalog. - Forms are set up by people, not agents, and the account already exists. The overlap is real: both are form backends. The split is the starting point, an account and a dashboard on one side, an anonymous POST and a claim link on the other. ## Further reading - [Provisioning a form](/docs/forms/provisioning): the create call, anonymous versus keyed, and unclaimed limits. - [Agents and MCP](/docs/forms/agents-and-mcp): the hosted MCP server, the skill, and the copy-paste prompt. - [API reference](/docs/forms/api-reference): every endpoint, plus the interactive OpenAPI docs. - [Let your AI agent provision a form](/use-cases/agent-provisioned-forms-mcp): the mid-task provisioning workflow end to end. [See Lanes Forms](/forms) Source: https://lanes.sh/compare/lanes-forms-vs-formspree --- # Lanes Forms vs Basin > Basin is a hosted form backend with email notifications and spam filtering. Lanes Forms adds agent provisioning over MCP and no-signup creation. Basin and Lanes Forms are both form backends: point a form at an endpoint and submissions are captured, filtered, and delivered. The difference is how the form is created and who can create it. This page is about that creation story, and where each tool earns its keep. ## At a glance | Dimension | Lanes Forms | Basin | | --- | --- | --- | | Create a form | One POST or an MCP tool call | Set up in the dashboard | | Account to start | Not required; claim by email later | Required up front | | Agent friendly | Yes, provision over MCP mid-task | Manual setup | | Spam defense | Honeypot, rate limits, disposable-email rejection | Spam filtering | | Destinations | Email and Lanes storage with CSV export today; webhooks on the roadmap | Email and integrations | | Origin rules | Yes | Yes | | Unclaimed forms | Hold up to 25 submissions, claim within 7 days | Not applicable; account comes first | | Form snippets | Generated as HTML or React over MCP | Copied from the dashboard | | Docs | [Lanes Forms docs](/docs/forms/quickstart) | usebasin.com | ## Where Lanes Forms fits Lanes Forms wins on time to first submission and on agent workflows. There is no signup to start, and an agent can provision the endpoint itself while it builds your site. You claim it into a dashboard later. Provisioning is one `POST /v1/forms` where only a `schema` is required; the response returns a live `endpoint_url` and a claim link, and an optional `Idempotency-Key` header makes retries safe, so a retried call never duplicates a form. See [provisioning](/docs/forms/provisioning). While unclaimed, a form holds up to 25 submissions, forwards nothing, and expires after 7 days if never claimed, so an endpoint born mid-build waits safely for its owner. The agent path is a hosted MCP server whose `create_form` and `submit_form` tools work with no key at all, covered in [agents and MCP](/docs/forms/agents-and-mcp). ## Where Basin fits Basin is a focused, friendly form backend with solid spam handling and notifications, managed from its dashboard. If a straightforward hosted backend set up by hand is what you want, Basin does that well. When the person setting up the form is a human with an account, the two are close; the gap opens when the creator is an agent, or the account does not exist yet. ## How Lanes does it - **Forms are born from an API call.** `POST /v1/forms` with a `schema` returns a working endpoint, anonymously or with an `lfk_` workspace key that makes the form born claimed with no claim step. Claiming an anonymous form releases its held submissions and turns forwarding on. See [provisioning](/docs/forms/provisioning). - **Two security axes.** An `allowed_origins` hostname allowlist governs browser posts, and an optional `submission_auth` of `api_key` requires an `lfk_` key from servers and other no-origin callers. Per-form CORS echoes the exact allowed origin, never a wildcard. See [securing your endpoints](/docs/forms/securing-your-endpoints). - **Delivery is explicit about what is live.** Email forwarding to verified recipients (up to 5) and Lanes-hosted storage with CSV export work today; webhook and custom-store delivery are on the roadmap and return `422 action_not_available` until they ship. See [integrating](/docs/forms/integrating). ## Choose Lanes Forms if - You want no-signup creation or agent provisioning over MCP. - You want email forwarding and stored submissions with CSV export now, and webhook routing when it ships. - You want a form an agent can create mid-task and hand off with a claim link. - You want origin allowlisting and an optional API-key mode on the same endpoint. ## Choose Basin if - You prefer to set up and manage forms in an established dashboard. - Every form you need is created once, by a person, and managed by hand. Both are honest tools for the same job. The difference is who, or what, creates the form. ## Further reading - [Provisioning a form](/docs/forms/provisioning): anonymous versus keyed creates, and unclaimed limits. - [Securing your endpoints](/docs/forms/securing-your-endpoints): origin allowlists, API-key mode, and CORS. - [Agents and MCP](/docs/forms/agents-and-mcp): the hosted MCP server, the skill, and the prompt path. - [Contact and support forms](/use-cases/contact-and-support-forms): the classic contact form on Lanes Forms. [See Lanes Forms](/forms) Source: https://lanes.sh/compare/lanes-forms-vs-basin --- # Lanes Forms vs Typeform > Typeform builds and hosts polished forms. Lanes Forms is a backend for the form you design yourself, provisionable by an agent. Typeform and Lanes Forms sit in different categories. Typeform is a form builder: it hosts a polished, one-question-at-a-time form you assemble in its editor. Lanes Forms is a backend: it captures and routes submissions from a form you build and style yourself. This page is about that category line, and which side of it your form sits on. ## At a glance | Dimension | Lanes Forms | Typeform | | --- | --- | --- | | Category | Form backend and endpoint | Hosted form builder | | Who builds the UI | You, inside your own product | Typeform's editor | | Setup | One request or an MCP call, no signup | Build in the dashboard | | Agent friendly | Yes, provision over MCP | Manual | | Destinations | Email and Lanes storage with CSV export today; webhooks on the roadmap | Typeform storage and integrations | | After submit | Browser 303 to a hosted thanks page, JSON receipt for APIs | Typeform's own flow | | Validation | Server-side against your schema, a 422 lists missing fields | Built into the editor | | Docs | [Lanes Forms docs](/docs/forms/quickstart) | typeform.com | ## Where Lanes Forms fits Lanes Forms is for when the form is part of your product and you want full control of the design. You build the fields and styling; Lanes captures, protects, and routes the submissions. No signup to start, and an agent can provision it for you. Getting there is one request: `POST /v1/forms` with the fields you need, drawn from six types (text, email, textarea, number, checkbox, hidden) with optional required flags, as described in [provisioning](/docs/forms/provisioning). You then point your own markup at the returned endpoint, either a plain HTML form or a `fetch` call, per [integrating](/docs/forms/integrating). Delivery today is email forwarding to verified recipients plus Lanes-hosted storage with CSV export; webhook delivery is on the roadmap. The three-step version of all this is the [quickstart](/docs/forms/quickstart). ## Where Typeform fits Typeform is for when you want a beautiful, hosted form experience without building any UI. Its conversational format and templates are its strength, and the form lives on Typeform rather than in your codebase. Lanes Forms does not compete with that: there is no hosted builder here, only the backend behind a page you own. ## How Lanes does it - **You own the markup, Lanes owns the plumbing.** Point a plain HTML form's `action` at the endpoint, or POST JSON from `fetch` or your server. A browser post gets a 303 redirect to a hosted thanks page; an API caller gets a JSON receipt with a submission id. See [integrating](/docs/forms/integrating). - **The schema is the form.** Declare the fields once and the server validates every submission against them, returning a 422 that lists exactly which required fields were missing. Values outside the schema are stored as extras rather than rejected, so adding a field to your markup does not break collection. See [provisioning](/docs/forms/provisioning). - **Agents can do the whole thing.** A hosted MCP server exposes `create_form` and `submit_form` with no key, and `generate_form_snippet` emits ready-to-paste HTML or React, so a coding agent can stand up the form while it builds the page. See [agents and MCP](/docs/forms/agents-and-mcp). ## Choose Lanes Forms if - The form belongs in your own product, with your design. - You want an endpoint an agent can create, and routing to your stack. - You want submissions stored with CSV export and forwarded to verified recipients by email. - You want built-in spam defense: a honeypot field, a per-IP rate limit, and a spam score on every stored submission. ## Choose Typeform if - You want a polished hosted form and do not want to build the UI. - The conversational, one-question-at-a-time format fits what you are collecting. Plenty of teams use both: a hosted builder for standalone surveys, and a backend for the forms that live inside the product. ## Further reading - [Quickstart](/docs/forms/quickstart): provision, integrate, and claim in three steps. - [Integrating a form](/docs/forms/integrating): HTML, fetch, and server wiring, plus where submissions go. - [Provisioning a form](/docs/forms/provisioning): every option on the create call. - [Feedback and survey forms](/use-cases/feedback-and-survey-forms): running surveys on pages you own, with export. [See Lanes Forms](/forms) Source: https://lanes.sh/compare/lanes-forms-vs-typeform --- # Lanes Forms vs Google Forms > Google Forms is a free hosted form builder. Lanes Forms is a backend endpoint for forms you build into your own product. Google Forms is a free, hosted form builder: you make a form in Google's UI and responses go to a Google Sheet. Lanes Forms is a backend for forms that live in your own product, with routing to email and Lanes-hosted storage today, and webhooks on the roadmap. Choose by whether the form lives on Google's page or yours. This page is about that split, and what owning the endpoint gets you. ## At a glance | Dimension | Lanes Forms | Google Forms | | --- | --- | --- | | Category | Form backend for your own site | Hosted form builder | | Where the form lives | In your product, your design | On a Google-hosted page | | Setup | One request or an MCP call, no signup | Build in Google Forms | | Destinations | Email and Lanes storage with CSV export; webhooks on the roadmap | Google Sheets | | Agent friendly | Yes, provision over MCP | Manual | | After submit | Browser 303 to a hosted thanks page, JSON receipt for APIs | On the Google-hosted page | | Programmatic use | REST API with interactive OpenAPI docs | Spreadsheet-first workflow | | Docs | [Lanes Forms docs](/docs/forms/quickstart) | forms.google.com | ## Where Lanes Forms fits Lanes Forms is for forms that are part of your product: a signup on your landing page, a contact form in your app. You own the design and the endpoint captures and routes the data anywhere you work. The endpoint serves both people and programs. It accepts JSON, form-urlencoded, and multipart posts; a visitor submitting from a browser is redirected to a hosted thanks page, while a script gets a JSON receipt with a submission id, per [submitting](/docs/forms/submitting). Delivery today is email forwarding to verified recipients plus Lanes-hosted storage, where responses list in the dashboard and export as CSV with `?format=csv`; webhook delivery is on the roadmap. See [integrating](/docs/forms/integrating). ## Where Google Forms fits Google Forms is a fast, free way to stand up a standalone form when you do not need it embedded in your own product and a Google Sheet is a fine home for the responses. The trade is ownership: the form, the page, and the responses all live in Google's tools rather than in yours. ## How Lanes does it - **One endpoint, two audiences.** `POST /v1/f/{form_id}` decides from the Accept header whether you are a browser or an API caller: browsers get a 303 to a hosted thanks page, everything else gets JSON. Required fields are validated server-side, and a 422 lists exactly which were missing. See [submitting](/docs/forms/submitting). - **Bots stay out of your responses.** A hidden `_gotcha` honeypot silently marks bot posts as spam while still returning success, submissions are limited to 10 per minute per IP, bodies cap at 64 KB, and every stored submission carries a spam score. IPs are stored only as salted hashes. See [submitting](/docs/forms/submitting). - **Agents can provision it.** A hosted MCP server exposes `create_form` and `submit_form` with no key, so a coding agent can create the endpoint mid-build and hand you a claim link. The full endpoint map, with interactive OpenAPI docs, is in the [API reference](/docs/forms/api-reference). See [agents and MCP](/docs/forms/agents-and-mcp). ## Choose Lanes Forms if - The form is part of your own product, with your styling. - You want submissions routed to your stack, not just a spreadsheet. - You need programmatic access: a REST endpoint, JSON receipts, and CSV export. - You want an agent to provision the form over MCP while it builds the page. ## Choose Google Forms if - You want a free, standalone hosted form and Sheets is enough. - Nobody is writing code, and a familiar Google page fits the audience. The two barely compete: one is a hosted page with a spreadsheet behind it, the other is an endpoint behind a page you own. ## Further reading - [Submitting data](/docs/forms/submitting): the endpoint contract for browsers, servers, and agents. - [Integrating a form](/docs/forms/integrating): HTML, fetch, and server wiring, plus where submissions go. - [API reference](/docs/forms/api-reference): every endpoint, plus the interactive OpenAPI docs. - [Feedback and survey forms](/use-cases/feedback-and-survey-forms): collecting responses on pages you own. [See Lanes Forms](/forms) Source: https://lanes.sh/compare/lanes-forms-vs-google-forms --- # Lanes Compute vs RunPod > Both give you on-demand GPUs with per-second billing. Here is how Lanes Compute compares, and where each fits. RunPod and Lanes Compute both rent GPUs on demand and bill by the second. RunPod is an established platform with a large catalog and serverless endpoints. Lanes Compute focuses on curated GPUs from A100 to H200 and multi-node clusters, as part of the Lanes family of tools. It is in early access. Concretely, Lanes Compute is the compute arm of [Lanes](/overview): A100, H100, and H200 SXM cards, plus L40S and other options for lighter jobs, from a single card to clusters wired with InfiniBand and NVLink, billed per second, running your own containers. This page covers where each platform fits. ## At a glance | Dimension | Lanes Compute | RunPod | | --- | --- | --- | | Focus | On-demand GPUs and multi-node clusters | GPU cloud with pods and serverless endpoints | | GPUs | A100, H100, H200, L40S | Large GPU catalog | | Multi-node | Yes, InfiniBand and NVLink | Available | | Billing | Per second | Per second | | Bring your own stack | Yes, your own containers | Yes, containers | | Serverless endpoints | No, you run your own serving stack | Yes | | Ecosystem | Part of the Lanes family of tools | Big community and templates | | Status | Early access | Generally available | | Docs | [Lanes Compute](/compute) | runpod.io | ## Where Lanes Compute fits Lanes Compute is for teams that want curated, consistent GPUs and multi-node clusters with fast interconnect, and who value being inside one family of tools for the agentic era. You bring your own stack and pay per second. Curated means a short menu provisioned the same way every time: A100, H100, and H200 SXM, plus L40S for lighter jobs, on demand or reserved for longer runs. Clusters arrive wired, with InfiniBand between nodes and NVLink within each node, so distributed [training](/use-cases/multi-node-h100-clusters) and [fine-tuning](/use-cases/fine-tune-llms) scale without extra setup. Persistent network volumes keep datasets and checkpoints in place between runs, and you choose the region. ## Where RunPod fits RunPod is a mature, broad platform. If you want a large catalog, serverless GPU endpoints, and a big community with lots of templates, RunPod is a strong, proven choice. Breadth is a real advantage when your needs vary: many card types, many configurations, endpoints when serverless fits the job. If you value options over a fixed, known setup, that model serves you well. ## How Lanes does it - **A short, consistent GPU menu.** A100, H100, and H200 SXM, with L40S and other options for lighter workloads. You get the card you asked for, provisioned the same way on every run, on demand or reserved for longer runs. - **Clusters that arrive wired.** InfiniBand between nodes, NVLink within a node, from one card up to multi-node, so large runs scale across boxes without assembly work. - **Billing that follows the job.** Per second while the environment is live, nothing after teardown. The pattern is covered in [right-size and pay per second](/use-cases/right-size-and-pay-per-second). - **One family of tools.** A CLI and API drop into your existing agents and tooling, and compute sits alongside the rest of [Lanes](/overview) rather than arriving as another vendor. Curation cuts both ways. A short menu cannot match a large catalog on choice, and it is not meant to: the bet is that training and serving jobs mostly land on a handful of cards, and that identical hardware on every provision is worth more than a longer list. ## Choose Lanes Compute if - You want curated GPUs and multi-node clusters with per-second billing. - You are already using the Lanes family of tools. - You want identical hardware on every provision, wired for distributed runs. - You keep datasets and checkpoints on persistent volumes between runs. ## Choose RunPod if - You want a large catalog and serverless endpoints today. - Your GPU needs change shape often and a broad catalog covers them. - You lean on community templates rather than your own images. Both bill per second and both run your containers, so the choice mostly reduces to breadth against curation. ## Further reading - [Right-size every job and pay per second](/use-cases/right-size-and-pay-per-second): match the card to the job and stop paying at teardown. - [Spin up multi-node H100 clusters](/use-cases/multi-node-h100-clusters): InfiniBand and NVLink for runs that span nodes. - [Fine-tune LLMs](/use-cases/fine-tune-llms): from a single-GPU LoRA pass to a multi-node full fine-tune. [See Lanes Compute](/compute) Source: https://lanes.sh/compare/lanes-compute-vs-runpod --- # Lanes Compute vs Vast.ai > Vast.ai is a GPU marketplace known for low prices. Lanes Compute offers curated on-demand GPUs and multi-node clusters. Vast.ai is a marketplace: you rent GPUs supplied by many providers, often at very low prices, with reliability that varies by host. Lanes Compute offers curated GPUs from A100 to H200 and multi-node clusters with fast interconnect, aimed at consistent hardware for training and serving. Lanes Compute is in early access. Concretely, Lanes Compute is on-demand GPU infrastructure with a deliberately short menu: A100, H100, and H200 SXM, plus L40S and other options for lighter jobs, from one card to multi-node clusters, billed per second, running your own containers. This page is about the trade the two models make: price against predictability. ## At a glance | Dimension | Lanes Compute | Vast.ai | | --- | --- | --- | | Model | Curated on-demand GPUs and clusters | Marketplace of provider-supplied GPUs | | Consistency | Curated, consistent hardware | Varies by host | | Multi-node | Yes, InfiniBand and NVLink | Depends on the offer | | GPUs | A100, H100, H200 SXM, plus L40S | Whatever hosts supply | | Storage | Fast persistent network volumes | Depends on the offer | | Billing | Per second | Market priced | | Status | Early access | Generally available | | Docs | [Lanes Compute](/compute) | vast.ai | ## Where Lanes Compute fits Lanes Compute is for when you want predictable hardware and real multi-node clusters, with fast interconnect for distributed runs, and per-second billing. Consistency and cluster quality are the point. In practice that means the card you asked for, provisioned the same way every time, with InfiniBand between nodes and NVLink within each node when a job spans machines. Persistent network volumes keep datasets and checkpoints in place between runs, you choose the region, and reserved capacity covers longer runs. The [compute FAQ](/compute#faq) covers GPUs, billing, and data handling. ## Where Vast.ai fits Vast.ai is hard to beat on price and breadth. If your priority is the cheapest available GPU and you can work around variable hosts, the marketplace model is a genuine advantage. A marketplace matches buyers to whatever hardware providers list. Supply is broad and prices float with it, which is exactly what cost-driven work wants. The flip side is that the machine, the network, and the host are properties of each offer rather than constants, which is the variability the table above describes. ## How Lanes does it - **One consistent supply.** A100, H100, and H200 SXM today, with L40S and other options for lighter workloads, taken on demand or reserved for longer runs. There is no marketplace layer: you request hardware and get the same thing each time. - **Clusters as a product.** Multi-node capacity comes wired with InfiniBand between nodes and NVLink within a node, sized from a single card upward, for [training runs](/use-cases/train-models-on-demand-gpus) that outgrow one box. - **Per-second billing with persistent storage.** You pay while the environment is live and nothing after teardown, and fast network volumes hold datasets and checkpoints between runs. See [right-size and pay per second](/use-cases/right-size-and-pay-per-second). - **Your stack and your data.** Bring your own containers and frameworks, PyTorch, JAX, and vLLM among them. You choose the region, and Lanes does not train on your data or share it. Price and predictability pull in opposite directions, and no platform escapes the trade. A marketplace lets you chase the price floor on each run; a curated service charges for the certainty that every run lands on known hardware. Which side wins depends on what a lost or slowed run costs you. ## Choose Lanes Compute if - You want curated, consistent hardware and multi-node clusters. - A lost or slowed run costs you more than the gap between offers. - Distributed jobs need interconnect that behaves the same run after run. - You want per-second billing with volumes that persist between runs. ## Choose Vast.ai if - Lowest price and marketplace flexibility matter most. - Your workloads tolerate host variability and you can retry when needed. - You are running short, cost-driven jobs where any capable card will do. Both positions are coherent. The marketplace is built for price discovery, Lanes Compute for repeatability, and workloads tend to sort cleanly into one or the other. ## Further reading - [Right-size every job and pay per second](/use-cases/right-size-and-pay-per-second): pick the card per job and stop paying at teardown. - [Train models on on-demand GPUs](/use-cases/train-models-on-demand-gpus): rent exactly what a training run needs, when it needs it. - [Batch processing and experiments](/use-cases/batch-processing-and-experiments): spin up per task and pay only for the window it runs. [See Lanes Compute](/compute) Source: https://lanes.sh/compare/lanes-compute-vs-vast-ai --- # Lanes Compute vs Modal > Modal runs your Python code on GPUs serverlessly. Lanes Compute gives you the GPUs and clusters to run your own stack. Modal and Lanes Compute approach GPU compute from different ends. Modal is code-first: you write Python functions and Modal runs them on GPUs on demand, abstracting the infrastructure away. Lanes Compute is infrastructure-first: you get the GPUs or a multi-node cluster and run your own stack on them. Lanes Compute is in early access. Concretely, Lanes Compute rents you A100, H100, and H200 SXM cards, plus L40S and other options for lighter workloads, from a single GPU to a multi-node cluster, billed per second, with your own containers on top. This page is about which end of that spectrum fits your work, and when both do. ## At a glance | Dimension | Lanes Compute | Modal | | --- | --- | --- | | Model | GPUs and clusters you run your stack on | Serverless: your Python runs on managed GPUs | | Control | Full control of the machine and stack | Managed infrastructure, code-first | | Multi-node | Yes, InfiniBand and NVLink | Managed scaling | | Bring your own stack | Yes, your own containers | Python-first with your dependencies | | GPUs | A100, H100, H200 SXM, plus L40S | Managed GPU pool | | Storage | Fast persistent network volumes | Managed by the platform | | Billing | Per second | Per second | | Status | Early access | Generally available | | Docs | [Lanes Compute](/compute) | modal.com | ## Where Lanes Compute fits Lanes Compute is for when you want direct control of the hardware: a specific GPU, a real multi-node cluster, your own container and framework. You run your stack, not a platform's abstraction of it. That control earns its keep when the workload does not fit the shape of a function: a long training or fine-tuning run, a serving stack such as vLLM that you tune yourself, or a distributed job that needs InfiniBand between nodes and NVLink within them. You pick the card, attach persistent network volumes so datasets and checkpoints survive between runs, choose the region near your data, and pay per second while the environment is live. Once it is torn down, billing stops. ## Where Modal fits Modal is for when you want infrastructure to disappear behind your code. If a serverless, Python-first developer experience with autoscaling suits your work, Modal is excellent at it. Serverless is a good trade when jobs are short, bursty, and shaped like functions: the platform handles machines and scaling, and you think in code. The price of that convenience is distance from the hardware, which starts to matter once you want to shape the machine, the interconnect, or the runtime yourself. ## How Lanes does it - **You pick the hardware.** A100, H100, or H200 SXM today, with L40S and other options for lighter workloads, from a single card to a multi-node cluster, taken on demand or reserved for longer runs. - **Your containers, unchanged.** Bring your own images and frameworks: PyTorch, JAX, vLLM, Axolotl, DeepSpeed, or anything else that runs in a container. A CLI and API drop into your existing agents and tooling. See [bring your own stack](/use-cases/bring-your-own-stack). - **Billing follows the run.** Per-second billing while the environment is live, nothing after teardown, and persistent volumes that hold datasets and checkpoints between runs. For serving, pick a card sized to your traffic, as in [inference and model serving](/use-cases/inference-and-model-serving). - **Early access, quick onboarding.** Request access with the form on [the compute page](/compute#request-access), describe the workload, and onboarding usually takes a day or two. The dividing line is who owns the runtime. On Modal, the platform owns it and your code plugs in. On Lanes Compute, you own it: the container, the framework, the serving process. Pick by the shape of your workload, not by either platform's framing. ## Choose Lanes Compute if - You want direct control of GPUs and clusters, running your own stack. - Your work is long training or fine-tuning runs, or a serving stack you operate yourself. - You need multi-node jobs with real interconnect between nodes. - You want persistent volumes and per-second billing around your own containers. ## Choose Modal if - You want a code-first, serverless experience that manages the infra for you. - Your jobs are short, bursty, and function-shaped. - You would rather not think about machines at all. The two models are not exclusive. Some teams keep function-shaped jobs on a serverless platform and run training and serving on infrastructure they control. ## Further reading - [Bring your own stack](/use-cases/bring-your-own-stack): run PyTorch, JAX, vLLM, and your own containers on Lanes Compute. - [Inference and model serving](/use-cases/inference-and-model-serving): serve models on GPUs sized to your traffic, with your own serving stack. [See Lanes Compute](/compute) Source: https://lanes.sh/compare/lanes-compute-vs-modal --- # Lanes Compute vs Lambda > Lambda is an established GPU cloud strong on training. Lanes Compute offers on-demand GPUs and multi-node clusters as part of the Lanes family. Lambda is a well-known GPU cloud, popular for training, with on-demand instances and multi-node H100 clusters. Lanes Compute similarly targets training and serving with A100 to H200 GPUs, multi-node clusters, and per-second billing, as part of the Lanes family of tools. Lanes Compute is in early access. The overlap here is real: both rent training-grade GPUs and both do multi-node clusters. Lanes Compute, concretely, is the compute arm of [Lanes](/overview): A100, H100, and H200 SXM cards from a single GPU to multi-node clusters, per-second billing, persistent network volumes for datasets and checkpoints, and your own containers on top. This page is about the edges where they differ. ## At a glance | Dimension | Lanes Compute | Lambda | | --- | --- | --- | | Model | GPUs and clusters you run your own stack on | GPU cloud with on-demand instances | | GPUs | A100, H100, H200, L40S | H100, H200, and more | | Multi-node | Yes, InfiniBand and NVLink | Yes, multi-node clusters | | Billing | Per second | On-demand and reserved | | Bring your own stack | Yes, your own containers | Yes | | Ecosystem | Part of the Lanes family of tools | Established GPU cloud | | Status | Early access | Generally available | | Docs | [Lanes Compute](/compute) | lambda.ai | ## Where Lanes Compute fits Lanes Compute is for on-demand GPUs and clusters with per-second billing, inside one family of tools for the agentic era. If you want compute that sits alongside the rest of your Lanes workflow, that is the fit. Beyond family fit, the concrete properties: billing at one-second grain, capacity taken on demand or reserved for longer runs, clusters wired with InfiniBand between nodes and NVLink within each node, and persistent network volumes that hold datasets and checkpoints between runs. The shape suits bursty training demand, where hardware is stood up for a run and torn down after it; see [train models on on-demand GPUs](/use-cases/train-models-on-demand-gpus). ## Where Lambda fits Lambda is a mature, proven GPU cloud with a strong reputation for training hardware and clusters. If you want an established provider with a track record, Lambda is a safe, capable choice. Track record is the one thing an early access product cannot offer, and it would be dishonest to pretend otherwise. If your first requirement is an established provider with a training reputation, that points to Lambda. ## How Lanes does it - **The same class of hardware.** A100, H100, and H200 SXM, from one card to multi-node clusters with InfiniBand between nodes and NVLink within a node; see [multi-node H100 clusters](/use-cases/multi-node-h100-clusters). - **Billing at one-second grain.** You pay while the environment is live and nothing after teardown, with reserved capacity for runs that hold hardware longer, so bursty and steady demand both have a shape that fits. - **Your containers and your data.** Bring your own images and frameworks, PyTorch, JAX, vLLM, Axolotl, and DeepSpeed among them. Persistent volumes hold datasets and checkpoints between runs, you choose the region, and Lanes does not train on your data or share it. - **Access matched to the workload.** Early access starts with the form on [the compute page](/compute#request-access): describe the job and the GPUs it needs, and onboarding usually takes a day or two. Early access against generally available is the honest headline here. Early access buys you a direct conversation about your workload and hardware matched to it during onboarding; what it costs you is the track record a provider like Lambda has had time to build. Weigh those two against each other. ## Choose Lanes Compute if - You want on-demand GPUs and clusters with per-second billing in the Lanes ecosystem. - You size capacity per run and want billing that stops at teardown. - You want clusters stood up for a run and torn down after it. - You would rather describe the workload and be matched to hardware than pick from a catalog. ## Choose Lambda if - You want a mature, proven GPU cloud for training. - An established track record is a hard requirement for your team. - You prefer a generally available service over an early access one. The overlap is genuine, so the decision mostly reduces to track record against billing grain and family fit. ## Further reading - [Train models on on-demand GPUs](/use-cases/train-models-on-demand-gpus): rent exactly what a training run needs, when it needs it. - [Spin up multi-node H100 clusters](/use-cases/multi-node-h100-clusters): the interconnect that makes distributed runs scale. [See Lanes Compute](/compute) Source: https://lanes.sh/compare/lanes-compute-vs-lambda --- # Lanes Link vs Composio > Both give an agent authenticated access to your accounts through MCP. Composio runs the integration layer for you across 1,300 toolkits. Lanes Link is a boundary you run yourself, and it is the whole product. Composio is one of the most complete tool layers an agent can call: over 1,300 toolkits, managed OAuth so you skip registering your own clients, sandboxed execution, triggers, and just-in-time tool search so a model is not handed a thousand schemas at once. Point a client at Composio Connect and you have working, authenticated tools in a single command. Lanes Link is a smaller idea, deliberately. It is one MCP endpoint you run yourself, holding your accounts, your memory, your skills, and your secrets behind one policy and one audit log. The question this page is really about is custody: whose infrastructure your tokens sit on, and whose runtime decides what an agent may do with them. ## At a glance | Dimension | Lanes Link | Composio | | --- | --- | --- | | Who runs it | You, on your machine or your own cloud | Composio's cloud. Self-hosting is an Enterprise arrangement | | Open source | Apache-2.0, and it is the endpoint itself | MIT, but the public repo is the SDKs and CLI. The runtime is theirs | | Where credentials sit | Your machine or your bucket | Composio's cloud, encrypted at rest. Enterprise KMS proxy keeps them ciphertext-only to Composio | | Coverage | Google, iCloud, Notion, and Linear today | Over 1,300 toolkits | | Work and personal | A required `profile` argument on every call | Multi-account with aliases. Explicit selection is opt-in; otherwise the most recently connected account is used | | Audit | One append-only, hash-chained record per call, on your storage | A queryable tool-execution log API, with rich per-call fields. Retention varies by plan | | Beyond dispatch | Memory, skills, and a secret vault behind the same boundary | Sandboxed execution, triggers, parallel calls, and tool search | | Billing | None. It is software you run | A free tier that covers a lot of individual use, then per tool call | | Docs | [lanes.sh/docs/link](https://lanes.sh/docs/link) | docs.composio.dev | ## Where Lanes Link fits Lanes Link is for people who want the boundary to be theirs. The endpoint runs on your machine or in your own cloud project, the tokens sit in your storage, and the audit log is a set of objects you own. Nothing about your accounts is a thing you have to trust a vendor with, because there is no vendor in the path. That ownership is also what makes the owner layer possible. Memory, skills, and the vault serve without any credential at all, so an endpoint with nothing connected is already useful, and what your agent knows about you never becomes someone else's dataset. Profiles round it out: work and personal share no configuration, no state, and no credentials, and every call names which world it acts within rather than relying on an ambient default. ## Where Composio fits If you want breadth, Composio wins and it is not close. Over 1,300 toolkits against a handful is the difference between "your tool is supported" and "you are waiting for a provider to land." Composio also runs the OAuth applications for you, so you can skip registering a client with each vendor, and it handles the parts nobody enjoys: refresh, retries, and the long tail of API quirks. Its free tier is genuinely generous for one person, so cost is not the argument against it. Neither is capability: sandboxed execution, triggers, parallel tool calls, and tool search are real engineering that a self-hosted personal endpoint does not attempt. Worth being clear about design intent, because it is easy to compare the wrong product. Composio's primary API is shaped around your end users, not you: you create a session for a `user_id`, white-label the connection page your users see, and pay per connection. It is built for a developer shipping integrations inside a product they sell. **Composio Connect** is the individual-facing path, and it is the fair thing to weigh against Lanes Link. If you are embedding integrations into something you ship to customers, that is Composio's job and not Lanes Link's, and no amount of self-hosting changes it. ## How Lanes Link does it - **The endpoint is the open-source artifact.** Apache-2.0, and running it yourself is the intended path rather than an enterprise tier. Providers are additive, and `docs/creating-a-provider.md` is written to be enough on its own. - **Deny by default, enforced at dispatch.** Every capability is denied until you allow it, and policy only tightens as a request travels inward. `gmail.search = allow` with `gmail.send = deny` is a decision the runtime makes before anything is sent, not a rule the model is asked to respect. - **The profile argument is required, not opt-in.** There is no current profile and nothing to switch, so there is no default to be silently wrong about. Naming a connection from another profile is refused before dispatch. - **The audit log is tamper-evident and yours.** One append-only event per invocation, refusals included, with per-provider redaction that keeps identifiers and withholds content. Records are hash-chained per run, and `lanes link audit verify` walks every chain. - **No database anywhere.** State and the log are objects in a blob store: a directory on your machine locally, a bucket when deployed. - **Local first.** It needs [Bun](https://bun.com) and no account anywhere. `lanes link deploy` stands up a Cloud Run revision on your own project when you need to reach it from the claude.ai web client, ChatGPT, or a phone. ## Choose Lanes Link if - You want your OAuth tokens on infrastructure you control, not a vendor's. - You want the endpoint itself to be open source and runnable, not an SDK against a hosted runtime. - You want memory, skills, and a secret vault behind the same boundary as your accounts. - You want work and personal enforced per call rather than resolved by a default. - You want a tamper-evident audit log that you own and that outlives anyone's retention policy. ## Choose Composio if - You need an integration Lanes Link does not have yet, which today is most of them. - You are embedding tool access into a product you ship to your own users. - You want sandboxes, triggers, or parallel execution as part of the platform. - You would rather someone else ran the OAuth applications and kept 1,300 connectors current. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): the permission model, profiles, and audit design in full. - [Give agents scoped access, with an audit trail](/use-cases/scoped-account-access-for-agents): deny-by-default in practice. - [Keep work and personal accounts apart](/use-cases/separate-work-and-personal-agent-access): why the profile argument is required rather than default. - [Lanes Link vs per-provider remote MCP servers](/compare/lanes-link-vs-remote-mcp-servers): the case for connecting each provider directly instead. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/compare/lanes-link-vs-composio --- # Lanes Link vs Zapier MCP > Zapier MCP puts 9,000 apps behind one hosted endpoint, on the credential infrastructure it has run for years. Lanes Link is a much smaller endpoint you run yourself, where scoping applies to the agent alone. Zapier MCP is the shortest path from an AI client to a very large number of apps. Around 9,000 of them, tens of thousands of actions, no code, and OAuth, refresh, and rate limiting all handled by a company that has been doing exactly that for over a decade. If the question is "can my agent touch this obscure SaaS tool," Zapier's answer is usually yes. Lanes Link answers a narrower question: who holds the credentials, and can you give an agent less access than you have yourself. It is one MCP endpoint you run on your own machine or your own cloud, and everything it serves sits behind a policy the endpoint enforces and an audit log you own. ## At a glance | Dimension | Lanes Link | Zapier MCP | | --- | --- | --- | | Who runs it | You, on your machine or your own cloud | Zapier, on multi-tenant cloud. Dedicated VPC and on-premises are not offered | | Open source | Apache-2.0, the endpoint itself | The server is closed source. The public repo holds client install assets | | Where credentials sit | Your machine or your bucket | Zapier's cloud, on the same credential infrastructure as its Zaps | | Coverage | Google, iCloud, Notion, and Linear today | Around 9,000 apps and tens of thousands of actions | | Default scoping | Deny by default. Nothing is reachable until you allow it | In the default mode the agent can enable actions for itself. Manual configuration turns that off | | Scope granularity | Per capability, on this endpoint alone | App and action restrictions apply account-wide across all Zapier features, not to MCP alone | | Work and personal | A required `profile` argument on every call | One server per named AI client. App connections are not shared between users | | Audit | One append-only, hash-chained record per call, on your storage | A History tab in the dashboard. Deleting a server also deletes its logs | | Billing | None. It is software you run | Two tasks per successful tool call, drawn from your Zapier plan's task pool | | Docs | [lanes.sh/docs/link](https://lanes.sh/docs/link) | docs.zapier.com/mcp | ## Where Lanes Link fits The sharpest difference is not breadth, it is scoping. Zapier documents the limitation plainly: app and action restrictions cannot currently be set for Zapier MCP alone, and anything you configure applies account-wide. So narrowing what the agent may do also narrows what you may do. Lanes Link's policy applies to the endpoint and nothing else, which means "this agent gets read-only mail" is a sentence you can act on without changing how you work. Custody is the other half. Your tokens sit in your storage, the audit log is a set of objects you own, and the whole endpoint is Apache-2.0 software you run. Add profiles, where every call names the world it acts within and crossing between them is refused before dispatch, and the owner layer of memory, skills, and a vault that serves without any credential at all, and you have a boundary that belongs to you rather than one you rent. ## Where Zapier MCP fits Nine thousand maintained connectors is not something a self-hosted endpoint is going to match, and pretending otherwise would be silly. Zapier also removes the work you would otherwise inherit: OAuth applications, token refresh, retries, and rate limiting across every one of those apps, kept current by people whose job that is. Auto-provisioning builds your toolset from apps you have already connected, and `write_code_action` generates a custom action when no built-in one fits. There is a real security property in there too. Every tool is owned and controlled by Zapier, and you cannot bring tools in from third-party sources, which forecloses tool poisoning in a way an endpoint assembling other people's MCP servers does not automatically get. And once you are on the platform, Zaps, Tables, and Agents sit on the same account and the same task pool, which is a lot of leverage from one integration. Two honest caveats about scale, in both directions. Task billing was designed for automation runs, where one task is a meaningful step, and an agent doing exploratory reads spends two tasks per call, so heavy read-heavy agent use gets expensive fast. And most of Zapier's governance story, from SSO to configurable retention to superadmin log review, is enterprise account machinery. It is not fair to credit Zapier with controls a solo user cannot reach, and equally not fair to fault it for lacking enterprise features in a comparison scoped to one person. ## How Lanes Link does it - **Policy is scoped to the endpoint.** Allowing and denying capabilities here changes what the agent can do and nothing else. Your own access to those accounts is untouched. - **Deny by default, enforced at dispatch.** Nothing is reachable until you allow it, and policy only tightens as a request travels inward. There is no mode in which the agent grants itself a new capability. - **Refusals are first-class.** Every refused call is recorded like any other, and `lanes link audit tail --denied-only` prints just those, which is the fastest way to see an agent repeatedly reaching for something you did not grant. - **The log is yours and tamper-evident.** One append-only event per invocation, hash-chained per run, with per-provider redaction that keeps identifiers and withholds content. `lanes link audit verify` walks every chain, and nothing deletes it but you. - **Profiles are named per call.** Work and personal share no configuration, state, or credentials, and naming a connection from another profile is refused before anything is dispatched. - **Local first, your cloud second.** It needs [Bun](https://bun.com) and no account anywhere. `lanes link deploy` rolls a Cloud Run revision on your own project when you need to reach it from the claude.ai web client, ChatGPT, or a phone. ## Choose Lanes Link if - You want to give an agent less access than you have, without restricting your own tools. - You want your credentials on infrastructure you control. - You want an audit log you own, that no server deletion takes with it. - You keep work and personal apart and want that enforced per call. - You want memory, skills, and a secret vault behind the same boundary as your accounts. ## Choose Zapier MCP if - You need one of the thousands of apps Lanes Link does not reach, which is most of them. - You want zero infrastructure and zero setup beyond authorizing a client. - You already run Zaps and want the agent on the same account and connections. - You value every tool being first-party and unable to come from an untrusted source. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): permissions, profiles, and audit in full. - [Give agents scoped access, with an audit trail](/use-cases/scoped-account-access-for-agents): what deny-by-default buys you day to day. - [Connect your accounts once, for every agent](/use-cases/one-endpoint-for-every-agent): one connection reached by every client you run. - [Lanes Link vs Composio](/compare/lanes-link-vs-composio): the other hosted tool layer, and where custody differs. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/compare/lanes-link-vs-zapier-mcp --- # Lanes Link vs per-provider remote MCP servers > Notion, Linear, and others ship their own hosted MCP servers. Lanes Link is a layer in front of them: one endpoint, one policy, one audit log, and the same accounts in every client. More services ship their own remote MCP server every month. You point your agent at Notion's, authorize it, and it works, with nothing to run and nothing to maintain. Repeat per service and per client, and you have a working setup. This is less a rivalry than a question of layering, and worth saying plainly: Lanes Link reaches Notion and Linear through their own remote MCP servers. The comparison is not "which server," it is whether you want a boundary of your own in front of them. ## At a glance | Dimension | Lanes Link | The provider's own MCP server | | --- | --- | --- | | Who runs it | You, on your machine or your own cloud | The provider | | Setup cost | An endpoint to start and keep current | Nothing to run | | Registration per client | One endpoint, every provider behind it | Each server, in each client | | Where credentials sit | Your machine or your bucket | The provider's cloud, on its own terms | | Permissions | Deny by default, per capability, enforced by the runtime | The scopes that provider chooses to expose | | Audit | One append-only, hash-chained record per call, across every provider | Per provider, where offered | | Work and personal | A required `profile` argument on every call | Usually a separate account or client per world | | Memory, skills, secrets | Served by the same endpoint, no credential needed | Out of scope | | Coverage | The providers Lanes Link implements today | Whatever that provider ships, immediately | | Docs | [lanes.sh/docs/link](https://lanes.sh/docs/link) | each provider's own docs | ## Where Lanes Link fits Lanes Link earns its keep when the count goes up: more providers, more clients, or more than one world to keep separate. Five services across three harnesses is fifteen registrations and fifteen consent screens done directly, and one endpoint plus five connections through Lanes Link. Revoking an account is one action rather than a hunt. It also owns the things no single provider can offer, because they are not any provider's job. One audit log that spans Gmail and Notion and Linear together. One policy language where `gmail.search = allow` and `gmail.send = deny` are enforced before dispatch. Profiles, so work and personal are separate worlds under one token. And the owner layer, memory, skills, and a vault, which serve without any account connected at all. ## Where per-provider servers fit If you use one service and one client, connecting directly is simply better. There is no endpoint to run, no process to keep alive, and no version of the integration that can lag behind the provider's own API, because it is the provider's own API. First-party servers also get new capabilities the day they ship, with no work from you and no waiting on anyone else's release. Be honest about scale before adding a layer. A self-hosted endpoint is a thing you own: it has to be running for your agent to reach anything, and on your own cloud it is a deployment with a lifecycle. That is a fair trade for a fleet of clients and a real permission boundary, and pure overhead for one person using one tool with one Notion workspace. ## How Lanes Link does it - **It aggregates rather than reimplements.** Notion and Linear are reached through their own remote MCP servers, and they register themselves. Lanes Link adds the boundary, not a second-guess of the provider's API. - **One registration covers every client.** `lanes link mcp add` finds Claude Code and Codex, registers the endpoint with each, and installs a skill and scout agent so the agent knows what it can reach. - **Policy is runtime, not prompt.** Every capability is denied until you allow it, and policy only tightens as a request travels inward. A refused call is recorded like any other. - **The audit spans providers.** One append-only event per invocation, with per-provider redaction that keeps the identifiers and withholds your content. `lanes link audit verify` walks the hash chains. - **Local first, cloud when you need it.** Locally it needs [Bun](https://bun.com) and no account anywhere. `lanes link deploy` stands up a Cloud Run revision when you need to reach the endpoint from the claude.ai web client, ChatGPT, or a phone. - **Providers are additive.** A new one lands without touching the core, and `docs/creating-a-provider.md` is written to be enough on its own. ## Choose Lanes Link if - You use several MCP clients and do not want to authorize each service in each one. - You want one audit log covering every provider, including the calls that were refused. - You want per-capability policy that the runtime enforces, not scopes the provider happens to expose. - You keep work and personal apart and want that enforced rather than remembered. - You want memory, skills, and a secret vault behind the same boundary as your accounts. ## Choose per-provider servers if - You use one or two services from one client, and adding an endpoint is more than the problem needs. - You want a provider's newest capabilities the moment they ship. - You would rather run nothing at all. These are not exclusive. Connecting a provider directly today and putting Lanes Link in front later costs you nothing, because the provider's server is what Lanes Link would be calling anyway. ## Further reading - [Lanes Link: one endpoint for everything an agent needs](/blog/introducing-lanes-link): the full picture, including permissions and profiles. - [Connect your accounts once, for every agent](/use-cases/one-endpoint-for-every-agent): the case for aggregating, in practice. - [Give agents scoped access, with an audit trail](/use-cases/scoped-account-access-for-agents): what the policy and audit layers actually do. - [Lanes MCP servers](/docs/mcp): the local and hosted MCP surfaces Lanes Desktop and Lanes Forms ship. Browse the source at [lanes-sh/link](https://github.com/lanes-sh/link), Apache-2.0, or read the [announcement](/blog/introducing-lanes-link). Source: https://lanes.sh/compare/lanes-link-vs-remote-mcp-servers