What a connector is
A connector is anything that lets an agent session, a loop, or the dashboard read or write a system that is not on your box: a Google Workspace account, a ClickUp workspace, a GitHub org, a WhatsApp number, a Telegram group.
Plexward does not implement those integrations itself. It leans on three existing mechanisms and then keeps track of them:
- MCP servers, either local processes that a brain launches over stdio, or remote HTTP endpoints, or connectors attached to your Claude account.
- CLIs that already hold their own auth, like
ghorvercel. - Sidecar services in this monorepo that own a session or a bot token and expose a local HTTP or CLI surface:
wa-senderfor WhatsApp,tg-bridgefor Telegram.
What Plexward adds is the bookkeeping: which entity is supposed to have which connector, whether it is actually working right now, and what the agent is allowed to do with it.
The four layers
Four layers behave completely differently, and confusing them is the single biggest source of connector trouble.
| Layer | Scope | Config lives in | Can it be per entity? |
|---|---|---|---|
account | your whole Claude account | claude.ai, not on disk | No. One ClickUp, one Notion, one Stripe, visible from every brain |
brain | one entity | <brain>/.mcp.json, gitignored | Yes, with an isolated credential directory |
cli | the machine | gh auth, vercel login, tailscale | No |
service | the deployment | wa-sender, tg-bridge, an automation server | Per-entity keys inside one shared service |
Only the first two are visible to claude mcp list. The rest report unprobed, which is honest, not broken.
Why isolation is a routing block, not a setting
Account-layer connectors are attached to your Claude account. There is exactly one ClickUp connector, one Notion connector, one Slack connector, and every brain on the machine can see all of them. There is no per-entity switch. People look for one, do not find it, and assume they configured something wrong.
Isolation at the account layer is therefore a doctrine, enforced in prose that the agent reads at the start of every session, in each brain's integrations/mcp-routing.md:
# MCP Tool Routing: Acme
## Owned Integrations
| MCP Tool | Service | Account / Workspace | Notes |
|---|---|---|---|
| acme-google | Gmail, Drive, Calendar | ops@acme.example | Local workspace-mcp, isolated credential dir |
| ClickUp | ClickUp | Acme workspace | Account layer. Only the Acme space. |
## Shared Integrations
| MCP Tool | Service | Notes |
|---|---|---|
| Gamma | Gamma | Shared across all entities |
## NOT This Company
Do NOT use the following tools in Acme context:
- Notion (belongs to another entity)
- Slack (belongs to another entity)
- claude.ai Gmail / Google Calendar / Google Drive (those reach a different
account; use `acme-google` instead)
That last bullet is the one that saves you. If you run a per-entity Google connector at the brain layer and you also have the account-layer Google connectors attached, an agent will happily reach for the wrong one and read the wrong mailbox. Name the account-layer tools in the NOT This Company block of every brain that has its own Google server.
Every brain needs this file. bin/brain-lint expects it, bin/mc-add-company generates a first draft from the connector selection you make, and the whole account layer depends on it being accurate.
Brain-layer connectors get real isolation: a separate credential directory, a separate OAuth consent, a separate token. If two entities genuinely must not see each other's mail, they belong at the brain layer, not the account layer.
Declared state and probed state
Connector state is split in two so a generated file never overwrites a curated one.
catalog/declarations.json DECLARED. Which connectors each entity is supposed
to have, plus the facts a probe cannot know: which
account, what it is for. Hand-edited, gitignored,
because it describes your portfolio.
connectors.json GENERATED. Declared state plus live health. Written
by bin/mc-connectors. The dashboard reads this.
Never hand-edit it.
Before this split, connectors.json was hand-maintained, which meant it was a snapshot pretending to be state: it went stale the day it was written. The first probe run against a nine-entity portfolio found four connectors that had been silently broken and six connectors in daily use that the hand-written inventory had never listed.
A declaration entry looks like this:
{
"companies": {
"acme": [
{ "name": "GitHub", "layer": "CLI/API", "svc": "Issues · Repos", "account": "acme org (@you)" },
{ "name": "acme-google", "layer": "local MCP", "svc": "Gmail · Drive · Calendar", "account": "ops@acme.example" },
{ "name": "ClickUp", "layer": "claude.ai", "svc": "Tasks", "account": "Acme workspace" },
{ "name": "Gamma", "layer": "shared", "svc": "Decks", "account": "all entities" }
]
}
}
The layer string decides whether the connector is probeable. local MCP, claude.ai, shared, mcp and plugin are probed. Anything else (a REST API, a self-hosted automation server, a bearer-token gateway) is real but unprobeable and reports unprobed rather than red. Add probe_name when the MCP server name differs from the display name.
The probe itself is claude mcp list, run with the brain as the working directory. It returns live per-server health for that brain's MCP config plus the account-level connectors. bin/mc-connectors parses it, cross-checks the cli layer with gh auth status, and writes connectors.json with four possible statuses:
| Status | Meaning |
|---|---|
active | probed and connected |
disconnected | probed and failing, needs authentication or erroring |
unprobed | declared, but this layer has no probe |
undeclared | detected in a brain's MCP config, declared in no catalog |
The two comparisons worth acting on fall out of that: declared minus detected is drift ("you declared Stripe, it says Needs authentication"), and detected minus declared is the connector someone added months ago and never wrote down.
Two caveats that matter. claude mcp list genuinely connects to every server to health-check it, so it takes seconds per entity and can hang: run it from cron or on demand, never on page load. And its output is a CLI display, not a stable API, so it is parsed defensively and degrades to unknown rather than to disconnected. A false red is worse than a blank.
Adding a connector to an entity
Three moves, always in this order.
1. Declare it. Add an entry to catalog/declarations.json under the entity's slug. This is the statement of intent, and it is what makes a missing connector show up as drift instead of silently not existing.
2. Authenticate it. This is the part that needs a human. Anything requiring a browser, a password, a credit card or a 2FA code is yours to do, not the agent's. What the step looks like depends on the layer:
account: attach the connector on claude.ai once. Every brain sees it.brain: write the server block into<brain>/.mcp.json, put the secret somewhere outside the repo, run the OAuth flow once per entity.cli:gh auth login,vercel login, once per machine.service: pair the session or create the bot, then add the entity's key to the service's own config.
3. Probe it. bin/mc-connectors --company acme --dry-run first, then for real. Confirm the row turns active rather than assuming it did.
Then write the connector into the brain's integrations/mcp-routing.md, under Owned, Shared or NOT This Company. A connector that works but is not routed will get used by the wrong entity eventually.
The secrets rule
No secret value is ever committed, written into a tracked file, pasted into a doc, or typed into the dashboard. Not once, not temporarily.
.mcp.jsonis gitignored and holds${ENV_VAR}references, never inline values. The tracked file is.mcp.json.example.- Values live either in a secrets backend (1Password, Vaultwarden) referenced from a committed
.env.tpl, or in~/.config/secrets/<service>.envat mode 600 when there is no backend yet. The launcher resolves the references at launch and injects them into the child process environment only, so the secret exists in memory for the session and never lands on disk. - Headless and cron runs that cannot unlock a vault run without injection and emit a degraded-credential event instead of blocking.
- The dashboard never collects a secret. Not one field, ever. Plexward spawns shells with your permissions; a web form that accepts API keys is exactly the shape this product spends its whole security posture avoiding. The onboarding flow generates a reference and tells you where the value goes.
- If a secret was ever world-readable or git-tracked, rotate it. Do not reason about whether anyone saw it.
Reading order for a fresh install
- GitHub. It is the default tracker and needs no OAuth dance.
- Telegram. Nothing else has a human-in-the-loop gate until this exists, and the briefings have nowhere to land.
- Google Workspace. The longest setup, and the one whose gotchas generalize to every other OAuth connector.
- Whichever tracker the entity actually lives in: ClickUp or Notion.
- Everything else, when an entity needs it.