Docs/Lanes Link/Reference/Configuration

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.

Code
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).

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:

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

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.

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); 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).

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

Interfacelocalcloud
SecretStoreencrypted fileGoogle Secret Manager
BlobStorefilesystemgcs, or any S3-compatible bucket
VaultStoreencrypted fileGoogle 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).

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:

OfflineNothing works. There is no local cache, because a second copy can disagree with the repository.
memory.searchReads 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 limitGitHub'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.
HistoryEvery write is a commit. That is the feature, and it means deleting an entry does not remove it from the history.
profile removeDoes 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 has the reasoning, including why this is the API rather than a clone.

Environment variables

LANES_LINK_HOMEWorkspace root. Otherwise the nearest ancestor holding lanes-link.yaml, else ~/.lanes-link.
LANES_LINK_PROFILENo 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_TARGETNo 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 / PORTBind address and port in a container.
LANES_LINK_CREDENTIAL_KEYbase64 32-byte key for the encrypted credential store.
LANES_LINK_VAULT_KEYbase64 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_TOKENConvention only: where lanes link mcp add codex tells the harness to read the bearer token from.
LANES_LINK_APP_SCHEMEWhich 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.