Using connectable.sh
This walks from a five-minute local setup to a hosted agent delegating to your own Claude Code. Design rationale is in design-universal-connectivity.md; flag and route reference in the README.
The mental model
You give an agent one MCP endpoint. Behind it sits everything you connected, and policy, approvals, caps and audit apply to all of it in one place.
agent (Claude Code, Cursor, Codex, your app)
│ one URL + one key
▼
connectable ──► policy · approvals · caps · audit · secret redaction
├─ MCP servers (local stdio, remote HTTP/SSE, OAuth)
├─ REST / GraphQL APIs, web pages
├─ drivers: postgres · mysql · sqlite · duckdb · duckdb-s3 · s3 · filesystem · agent
└─ Links ──► your laptop (local files, local DBs, local Claude Code)The agent sees tools namespaced <connector>__<tool>, such as warehouse__query or notes__read_file.
1. Quick start: self-hosted, file mode
// connectors.json
{
"connectors": [
{
"name": "notes",
"transport": "driver",
"driver": "filesystem",
"options": { "roots": ["~/Documents/notes"] }
},
{
"name": "warehouse",
"transport": "driver",
"driver": "postgres",
"options": {
"connectionString": "{{ env.WAREHOUSE_URL }}",
"schemas": ["analytics"],
"maxRows": 200
}
},
{
"name": "github",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "{{ env.GITHUB_TOKEN }}" }
}
]
}export WAREHOUSE_URL=postgres://readonly:…@db:5432/app GITHUB_TOKEN=ghp_…
connectable check connectors.json # validates driver options; fails on an unset {{ env.X }}
./dist/connectable serve connectors.json --port 3333 # self-host binary: bun run build:binary
claude mcp add --transport http connectable http://localhost:3333/mcpOutput from a run of the notes connector alone:
tools: notes__list_directory, notes__read_file, notes__search_files, notes__stat
read: "# Groceries\n- oat milk\n"
escape refused: true - "../connectors.json" is outside the configured rootsconnectable drivers lists every driver this binary serves.
Add a top-level "instructions" string to the same file to tell the agent what each connector holds. It's sent in the MCP initialize result, and Claude Code reads it as the server's instructions. Hosted endpoints carry their own: run connectable endpoints update <id> --instructions-file agent.md. See README › Telling the agent what's where.
2. The whole product from one binary
bun run build:binary # → dist/connectable (gateway, API, auth, link relay, CLI)
DATABASE_DRIVER=sqlite DATABASE_PATH=./connectable.db \
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -base64 32) \
./dist/connectable serve --multi-tenant --migrate --port 3333One process serves:
/mcp/<endpoint-slug>: the gateway, per tenant/api/v1: the management API (/api/v1/openapi.json)/api/auth: sign-in/api/v1/links/*: the link relay
Use DATABASE_DRIVER=postgres DATABASE_URL=… for Postgres. Migrations are embedded; --migrate applies them.
The dashboard is the Next app in packages/ui (or hosted connectable.sh) and is not inside the binary. Everything it does is an API call, so the binary plus the CLI or SDK is a complete headless deployment:
connectable login --url http://localhost:3333 --token cmt_…
connectable secrets set PG_URL 'postgres://readonly:…@db:5432/app'
connectable driver-connectors test warehouse --driver postgres \
--options '{"connectionString":"{{ secrets.PG_URL }}"}'
connectable driver-connectors create warehouse --driver postgres \
--options '{"connectionString":"{{ secrets.PG_URL }}","maxRows":200}'
connectable endpoints create … # scope connectors, then mint a ck_ keyOr skip the flags: connectable alone, in a terminal, opens the TUI. On first run it's a wizard for sign-in, import and client setup; after that it shows the approvals queue, connectors and sessions, live.
Connect a client (dashboard, connectable endpoints setup, or the TUI's wizard) prints snippets for Claude Code, Claude Desktop, Cursor, VS Code, Codex, opencode, Windsurf and curl.
2b. Fully headless, no browser
Every step below was run end to end against the binary; nothing opens a browser.
Self-hosted. The server owns the database and the CLI only talks to it:
export DATABASE_DRIVER=sqlite DATABASE_PATH=./connectable.db \
BETTER_AUTH_SECRET=… CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -base64 32)
./dist/connectable serve --multi-tenant --migrate & # gateway + API + relay
echo "$OWNER_PASSWORD" | connectable bootstrap --org Acme --email ops@acme.com \
--password-stdin --url http://localhost:3333 # org + owner + token, CLI logged inbootstrap goes through the API like every other client command: it signs up (or signs in, if the email has an account), creates the org, mints a full-access management token and logs the CLI in. --password-stdin is optional. Without it the account gets a random password nobody sees, so the token is the only way in. With it the owner can sign in to the dashboard later. --platform mints a token that can create and run other orgs. Migrations are the server's job (serve --migrate), not bootstrap's.
Hosted, logging in through the browser:
connectable login --url https://connectable.sh # prints ABCD-2345 and opens /cli/login
connectable login --url https://connectable.sh --no-browser # over SSH: open the printed URL yourselfThe CLI mints a token locally and sends only its hash. You approve the code in the dashboard (admin, in the workspace you pick) and the token the CLI already holds starts working. Codes expire after ten minutes. It shows up on the tokens page as cli <hostname>. See design-cli-web-login.md.
Hosted, with password login from the CLI:
connectable signup --url https://connectable.sh --email you@acme.com # prompts for a password
connectable login --url https://connectable.sh --email you@acme.com # later, or on another machine
CONNECTABLE_PASSWORD=… connectable login --url … --email … --org acme # non-interactiveThe CLI signs in, picks (or creates) the org, mints a token named for this machine and stores it; the session is discarded. Revoke a machine by revoking its cli <hostname> token.
Pair devices without approving a code, using join grants:
connectable links grant --name build-box --ttl 60 --uses 1 # prints clg_… once
# on the device (or in its provisioning script):
CONNECTABLE_LINK_GRANT=clg_… connectable link pair --url https://connectable.sh
connectable link connectable.link.jsonA grant is hashed at rest, expires, and is single-use by default (--uses 20 for a fleet). connectable links grants lists live ones; revoke-grant kills one.
OAuth for remote MCP servers, with consent on any device:
connectable oauth start <connector-id> --wait # prints the sign-in URL, blocks until doneOpen the URL on your phone or any machine. The callback is authenticated by its single-use state (30-minute window), not by a connectable session, so the device that consents never needs to sign in to connectable. The provider's own consent screen is the one human step that cannot be removed; for providers that offer API keys, use an http connector with an Authorization header instead and skip OAuth entirely.
Everything else is ordinary CLI:
connectable secrets set PG_URL 'postgres://…'
connectable driver-connectors create warehouse --driver postgres --options '{"connectionString":"{{ secrets.PG_URL }}"}'
connectable links add-connector <link-id> notes --name laptop-notes
connectable endpoints create --name team --connectors <id>,<id>
connectable endpoints key-create <endpoint-id> # ck_… on stdout
connectable endpoints setup <endpoint-id> # client snippets3. Happy paths
Let an agent query your database safely
Dashboard: Connectors → Add → Database & storage → PostgreSQL → paste the URL (stored as an org secret) → Test connection → Create.
Tools: list_tables, describe_table, query, plus a schema://tables resource.
Guaranteed whatever the agent sends:
- One read-only statement per call, inside
BEGIN READ ONLYwith a statement timeout. DELETE,COPY, multi-statement input,pg_read_file(),dblink()andEXPLAIN ANALYZE <write>are refused.- The row cap is reported (
truncated: true), never silent. - Results are a text table and structured
{ columns, rows }. - Test connection warns when the credential can write. Use a read-only role.
Scope it to what matters, and tell the model what the data means:
"options": {
"connectionString": "{{ secrets.PG_URL }}",
"schemas": ["analytics"],
"tables": ["orders*", "customers"],
"excludeTables": ["*_pii"],
"instructions": "Revenue is analytics.orders.amount_cents"
}driver: "mysql" is identical (MySQL, MariaDB, PlanetScale, TiDB, Aurora MySQL).
Browse an S3 / R2 bucket
{
"name": "exports",
"transport": "driver",
"driver": "s3",
"options": {
"bucket": "acme-exports",
"prefix": "reports/",
"region": "us-east-1",
"accessKeyId": "{{ secrets.AWS_KEY }}",
"secretAccessKey": "{{ secrets.AWS_SECRET }}"
}
}- Tools:
list_objects,get_object(text as text, images as images,rangefor partial reads),head_object. - Opt-in:
presign_get(allowPresign),put_object/delete_object(allowWrites). - Keys never leave
prefix;../is refused. - R2, RustFS, MinIO, B2: set
endpoint.
SQL over Parquet/CSV on your machine (DuckDB)
{
"name": "lake",
"transport": "driver",
"driver": "duckdb",
"options": { "allowedPaths": ["~/data/exports"] }
}SELECT region, sum(revenue) FROM read_parquet('~/data/exports/*.parquet') GROUP BY 1DuckDB itself refuses files outside allowedPaths, and the configuration is locked against the query changing it. Needs the duckdb CLI (1.2 or later) on that machine.
lake__list_tables lists the data files under allowedPaths alongside the database's tables, so the agent can find them without being told:
files./Users/you/data/exports/2026-10-01.parquet (file)
files./Users/you/data/exports/2026-10-02.parquet (file)describe_table takes one of those paths and returns its columns. The listing walks four directories deep, skips dotfiles and stops at 500 files. Past that it tells the agent to query a glob.
SQL over Parquet/CSV in a bucket (DuckDB over S3)
duckdb-s3 reads nothing on the gateway's disk, so unlike duckdb it runs on a hosted gateway. Options are the s3 driver's, minus writes.
{
"name": "warehouse",
"transport": "driver",
"driver": "duckdb-s3",
"options": {
"bucket": "acme-lake",
"prefix": "events/",
"region": "auto",
"endpoint": "https://<account>.r2.cloudflarestorage.com",
"accessKeyId": "{{ secrets.R2_KEY }}",
"secretAccessKey": "{{ secrets.R2_SECRET }}"
}
}SELECT date_trunc('day', ts) AS day, count(*) FROM 's3://acme-lake/events/2026/*.parquet' GROUP BY 1list_tables lists data files under the prefix as files.s3://acme-lake/events/….
DuckDB never gets your key or your endpoint. Its S3 secret points at a loopback proxy inside the gateway that holds a random token in place of the key. The proxy forwards only GET and HEAD, refuses any key outside prefix (.. included), re-signs the request, and refuses redirects instead of following them. httpfs on its own follows a redirect from the endpoint to any host, which is SSRF on a shared gateway.
Needs the duckdb CLI with httpfs installed (duckdb -c "INSTALL httpfs"). The driver turns extension autoinstall off, so a query can't download one. The gateway's Docker image ships both.
A GraphQL API behind a token endpoint
{
"name": "acme",
"transport": "graphql",
"specUrl": "https://api.acme.com/graphql",
"include": ["Query.*"],
"auth": {
"type": "oauth2_client_credentials",
"tokenUrl": "https://auth.acme.com/oauth/token",
"clientId": "{{ env.ACME_CLIENT_ID }}",
"clientSecret": "{{ env.ACME_CLIENT_SECRET }}"
}
}Introspection and every call carry the token, and a 401 fetches a new one. For a login flow that isn't client credentials, use "type": "hook" with your own JavaScript. See README › APIs that hand out tokens.
A remote MCP server that needs OAuth (Linear, Notion, Sentry…)
Add an HTTP connector with the server URL, then click Connect on the Connectors page. That runs discovery, client registration and a PKCE sign-in, then returns to the dashboard. Tokens are stored encrypted per connector and refreshed at dial time. Servers that still speak the legacy SSE transport are detected automatically.
CLI: connectable oauth start <connector-id> prints the sign-in URL.
Import the servers you already use
connectable import-mcp claude-desktop # cursor | claude-code | windsurf | vscode | codex | ./file.jsonSelf-hosted: writes connectors.json; tokens found in env or headers move to .env.connectable (mode 0600) as {{ env.NAME }}. Hosted: Connectors → Import a config, where you paste, preview and import. Credentials become org secrets; stdio servers are offered as a Link snippet.
4. Links: your laptop, reachable from a hosted gateway
connectable link pair --url https://connectable.sh
# Approve this device: https://connectable.sh/links/pair?code=ABCD-EFGHAn admin approves in the browser. The token was minted on your laptop; only its hash ever left it.
// connectable.link.json — same format as connectors.json
{
"connectors": [
{
"name": "notes",
"transport": "driver",
"driver": "filesystem",
"options": { "roots": ["~/notes"] }
},
{
"name": "localdb",
"transport": "driver",
"driver": "sqlite",
"options": { "path": "~/app/dev.db" }
},
{ "name": "figma", "transport": "stdio", "command": "npx", "args": ["-y", "figma-mcp"] }
]
}connectable link connectable.link.jsonDashboard → Links: the device shows online with its advertised connectors → Add as connector → scope it to an endpoint. Or: connectable links add-connector <link-id> notes --name laptop-notes.
- Outbound HTTPS long-poll: no open port, no tunnel, works behind NAT and proxies.
- Your local file is final. The gateway can call only what it lists.
- A call to an offline laptop fails fast with "link is offline".
- Revoking the link stops its token on the next request.
5. Advanced scenarios
A. A hosted agent delegates to your Claude Code, with a human in the loop
// in connectable.link.json
{
"name": "claude-code",
"transport": "driver",
"driver": "agent",
"options": {
"runner": "claude",
"cwd": "~/code/app",
"permission": "plan",
"maxBudgetUsd": 2,
"instructions": "Never push. Summarize findings with file:line."
}
}Add it via Links. On the endpoint, turn approvals on and set this connector's approval policy to all.
A cloud agent calls claude-code__run_task { prompt: "why does checkout fail on empty carts?" } → the call waits in Approvals (a webhook can ping Slack) → you approve → your local Claude Code runs in plan mode in your checkout → the answer returns with session id, cost and changed files. Pass resume_session to continue.
"permission": "edit"lets it change files; the tool is then marked destructive.- There is no bypass mode.
runner: "codex"and"opencode"work the same way;"custom"runs any CLI.
A2. A web app uses your Claude Code
The studio at https://ohmyascii.example has a chat. With the bridge, it runs on the visitor's own Claude Code instead of an API key:
// connectable.link.json on the laptop
{
"connectors": [],
"apps": [{ "id": "ohmyascii", "mode": "text", "origins": ["https://ohmyascii.example"] }]
}connectable link pair --url https://connectable.sh # once
connectable bridge connectable.link.jsonThe app's backend mounts createBridgeProxy at /api/bridge with a token and the link id. The page calls connect({ app: 'ohmyascii', relay: '/api/bridge' }). From a localhost page it finds the bridge on loopback directly, with no deployment involved.
While the page is open, Claude Code in a terminal can act on it:
claude mcp add --transport http bridge http://127.0.0.1:4317/mcp
# tools: ohmyascii__get_scene, ohmyascii__set_scene, …B. An analyst agent across sources
One endpoint, analyst, scoping warehouse (Postgres, 500-row cap), exports (S3 prefix), lake (DuckDB via Link) and Notion (OAuth MCP). Guard it with:
- a tool profile exposing only
*__query,*__list_*,*__get_object,notion__search; - a spend cap of 500 calls/day and a per-key rate limit;
- description pinning in quarantine mode, so a tool whose description changes is blocked until someone reviews it.
C. Controlled writes
"allowWrites": true on the Postgres connector adds execute, annotated destructive. Set the endpoint's approval policy for that connector to write: reads flow, every execute waits for a human, and every call lands in the tamper-evident audit log.
D. Credentials that never leak
- Options hold
{{ secrets.NAME }}; the value is resolved only at dial time. - Rotate with
connectable secrets set NAME <new value>. Every connector using it picks it up, with no connector edits. - See who references a secret first:
GET /api/v1/secrets/NAME/usage. - If a database error or an upstream echoes the password, whether raw, URL-encoded or base64, the model sees
[secret:NAME]instead.
E. Your own connector type: a plugin driver
// my-crm-driver.ts
import { defineDriver, registerDriver } from 'connectable.sh';
import { z } from 'zod';
registerDriver(
defineDriver({
id: 'acme-crm',
label: 'Acme CRM',
category: 'other',
access: 'network',
description: 'Look up accounts',
fields: [{ name: 'apiKey', label: 'API key', type: 'secret', required: true }],
optionsSchema: z.object({
apiKey: z.string(),
baseUrl: z.string().url().default('https://api.acme.com'),
}),
// The sandbox checks these hosts, DNS resolved, before anything dials.
targets: (o) => [{ host: new URL(o.baseUrl).hostname, url: o.baseUrl }],
create: (o, ctx) => ({
listTools: async () => [
{
name: 'find_account',
inputSchema: { type: 'object', properties: { q: { type: 'string' } } },
annotations: { readOnlyHint: true },
},
],
callTool: async (_name, args) => {
const res = await ctx.fetch(
`${o.baseUrl}/accounts?q=${encodeURIComponent(String(args.q))}`,
{
headers: { authorization: `Bearer ${o.apiKey}` },
},
);
return { content: [{ type: 'text', text: await res.text() }] };
},
close: async () => {},
}),
}),
);{
"plugins": ["./my-crm-driver.ts"],
"connectors": [
{
"name": "crm",
"transport": "driver",
"driver": "acme-crm",
"options": { "apiKey": "{{ env.ACME_KEY }}" }
}
]
}It gets everything a built-in gets: governance, audit, circuit breaker, secret redaction and a generated dashboard form. Hosted deployments never load plugin code. Tenants choose drivers; they never supply them.
Driver contract: return an isError result for the caller's mistakes (bad SQL, missing file) and throw only when the source itself is unwell. Only throws count against the circuit breaker.
F. Automate everything
connectable links # devices + advertised connectors
connectable links add-connector <link-id> notes --name laptop-notes
connectable connectors import ~/.cursor/mcp.json --preview
connectable driver-connectors drivers # driver definitions, incl. form fields
connectable oauth connectionsThe typed SDK (ConnectableClient in packages/sdk) covers the same resources. Use it if you're a platform provisioning a tenant per customer.
G. Keep data inside your VPC
Run the gateway as a customer data plane (connectable data-plane --url …): configuration pulls down, signed audit metadata pushes up, credentials and tool traffic stay inside. With Links or local drivers, data never touches the hosted side.
H. Host the gateway on Cloudflare, one container per org
cd deploy/cloudflare && bun install
wrangler secret put DATABASE_URL # plus the rest of the fly.toml secrets
wrangler deployPoint clients at https://<worker>/o/<org>/mcp/<endpoint-slug>. The org prefix only picks the container: a key from another org gets a 404, so /o/<org> can't be used to reach someone else's endpoint. Old /mcp/<endpoint-slug> URLs get a 308 to the prefixed one; the TypeScript MCP SDK follows it, but update configs anyway. Setup and the pool layout are in the README under "Deploying to Cloudflare".
I. Host the dashboard on Cloudflare, or everything on Fly
cd packages/ui
bun run build:cloudflare && bun run deploy:cloudflare # secrets: README, "Deploying the dashboard to Cloudflare"The dashboard Worker forwards /mcp/* to the gateway Worker, runs the scheduled jobs as Cron Triggers and retries webhooks through a queue. To keep everything on Fly instead, deploy fly.toml (gateway) and fly.dashboard.toml (dashboard) against one Postgres; the dashboard runs the scheduled jobs in-process.
6. Limitations
- Local drivers (files, SQLite, DuckDB, agent) do not run on a hosted gateway or a free plan. Serve them through a Link.
- SQLite cannot be interrupted mid-statement; the timeout bounds the call, not the query.
- DuckDB requires the
duckdbCLI, 1.2 or later, on the machine that runs it.duckdb-s3also needs httpfs installed ahead of time. duckdb-s3is read-only and spawns oneduckdbprocess per call.- DuckDB's file listing stops at 500 files and four directories deep.
- Endpoint instructions reach new sessions only, and there's no org-wide default.
- A link's tool list is a snapshot, re-advertised every 60 s or on restart.
- Bridge runs through the relay are long-polled: each read is a management API request, so a busy app can hit the per-token (1000/min) and per-IP (600/min) limits. Reads linger 400 ms to batch deltas.
- The bridge's hosted route rides on the
linksscope, so the app's token needsconnectors:write, which can also create connectors. - Page tools reach agents on the bridge's own machine (
/mcp), not agents on the hosted gateway. - On Cloudflare, a container that sleeps or is redeployed drops its MCP sessions; clients start new ones. A client that won't follow a
308onPOSTneeds the/o/<org>/URL set by hand. - OAuth grants are per connector (org-wide), not per user. Per-user credentials are planned, not built.
- BigQuery, Snowflake, ClickHouse, MongoDB, Redis and Google Sheets are listed as planned drivers. Federated SQL across sources is not built.