connectable.sh

connectable.sh

One MCP endpoint for every tool your agents use.

Beta. connectable.sh is stable enough to put real traffic through and every call is on the record, but connectors, APIs and pricing are still moving, and some things will change without a migration path.

connectable.sh is an MCP gateway and a universal connector. It aggregates upstream MCP servers (local or remote, OAuth or not), wraps REST/GraphQL APIs and web pages, and serves databases, object storage, local files and even local coding agents as tools. All of it sits behind a single endpoint, with governance in front: per-tool allow/deny lists, rate limits, spend caps, human approval for destructive calls, and an audit log of every call.

Runs two ways from one codebase:

  • Self-hosted: a single process, connectors in a JSON file, secrets in your own store.
  • Hosted: multi-tenant, Postgres-backed, with a web dashboard.

Either way it's one binary: bun run build:binary produces dist/connectable, which is the gateway, the management API, sign-in, the link relay and daemon, and the CLI (see One binary).

USAGE.md walks through every path end to end: a five-minute local setup, databases and buckets, OAuth MCP servers, Links to your laptop, and a hosted agent delegating to your own Claude Code. examples/support-desk is a runnable offline example: a SQLite database and a folder of support guides behind one gateway, with a client script.

Install

npm install -g connectable.sh
# or
bun add -g connectable.sh

The package puts connectable on your PATH. It has the local commands (check, probe, tunnel, link, bridge, import-mcp, export, import), the TUI and every management command (connectors, endpoints, secrets, billing, deployments, audit and the rest; connectable help prints the full reference). It does not contain the gateway server: serve, data-plane and keygen are in the self-host binary only (see One binary and design-published-cli.md).

Quick start (self-hosted)

Create a connectors.json:

{
	"connectors": [
		{
			"name": "files",
			"transport": "stdio",
			"command": "npx",
			"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
		},
		{
			"name": "sentry",
			"transport": "http",
			"url": "https://mcp.sentry.dev/mcp"
		}
	]
}

Check it before starting anything:

connectable check connectors.json

Then start the gateway with the self-host binary (One binary):

./dist/connectable serve

Tools arrive namespaced as files__read_file, sentry__find_issues, and so on.

Wrapping a REST or GraphQL API

An upstream does not have to speak MCP. Point a connector at an OpenAPI document or a GraphQL endpoint and connectable introspects it, then serves the operations you pick as ordinary MCP tools. Calls are routed through the same policy, rate-limit, approval, pinning and audit path as everything else.

{
	"connectors": [
		{
			"name": "stripe",
			"transport": "openapi",
			"specUrl": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
			"headers": { "authorization": "Bearer sk_test_…" },
			"include": ["GetCustomers", "GetCharges", "GetChargesCharge"]
		},
		{
			"name": "linear",
			"transport": "graphql",
			"specUrl": "https://api.linear.app/graphql",
			"headers": { "authorization": "…" },
			"include": ["Query.issues", "Query.issue"]
		}
	]
}

include has no default. Omitting it gives you zero tools, not all of them. Stripe's spec is 612 operations at 583 tokens a tool, roughly 357k tokens of context if materialized whole (178% of a 200k window), so an agent handed the lot is worse off than one with no tools at all. Selection is also the only place a vendor credential's blast radius can be cut: the same key that reads charges can refund them, and picking three read operations is a real boundary where a deny-list written later is not.

The same rule holds when a spec changes: a newly appeared operation arrives as a candidate, never as a live tool, and a changed description on a tool you already approved is reported as drift rather than passed silently to your agent.

In the dashboard, Connectors → Add a connector offers OpenAPI and GraphQL cards: paste the document URL, and the wizard introspects it, lets you browse by tag, shows a live token meter as you tick operations, previews the exact HTTP request a tool would send, and enables only what you picked. No vendor is compiled in: a card that names somebody else's spec URL is a supply-chain assertion this codebase would have to keep true, and the operation counts above show why it matters that you chose the document. bun packages/core/scripts/measure-openapi-fidelity.ts reproduces the numbers above against live vendor specs (Stripe, GitHub, Slack, DigitalOcean and connectable's own). The totals from the October 2026 run:

$ bun packages/core/scripts/measure-openapi-fidelity.ts
--- corpus totals ---

operations measured    2175
clean schema rate      93%
operationId coverage   93%
read-only (retryable + cacheable)  49%
tokens/tool            306  (vs 214 for MCP tools)
description budget saves 12% of the untrimmed cost

DigitalOcean contributes zero operations: its spec splits into external files, and remote $refs are refused (845 of them), so import a bundled copy.

APIs that hand out tokens

headers covers a fixed API key. For an API that wants a short-lived token, add an auth block. The token is fetched when a call needs one, cached until it expires, sent after headers, and refetched once if the API answers 401. The spec fetch and GraphQL introspection use it too.

OAuth2 client credentials:

{
	"name": "acme",
	"transport": "graphql",
	"specUrl": "https://api.acme.com/graphql",
	"include": ["Query.orders"],
	"auth": {
		"type": "oauth2_client_credentials",
		"tokenUrl": "https://auth.acme.com/oauth/token",
		"clientId": "{{ env.ACME_CLIENT_ID }}",
		"clientSecret": "{{ env.ACME_CLIENT_SECRET }}",
		"scope": "orders:read"
	}
}

Anything else (a login call, a signed request, a token in a query parameter) is an auth hook. It's JavaScript run in a QuickJS sandbox, the same approach as reportable's auth hooks:

// sees: credentials (frozen), http.fetch(url, init), signing.sha256 / hmacSha256 / base64 / base64Url
const response = await http.fetch('https://auth.acme.com/login', {
	method: 'POST',
	headers: { 'content-type': 'application/json' },
	body: JSON.stringify({ key: credentials.key }),
});
return {
	headers: { authorization: `Bearer ${JSON.parse(response.body).token}` },
	expiresInSeconds: 600,
};
"auth": { "type": "hook", "credentials": { "key": "{{ env.ACME_KEY }}" }, "source": "…the code above…" }

The hook has no Node, filesystem, environment or global fetch. Its http.fetch goes through the same sandbox policy as the connector's own calls, redirects included (max 3). Limits per run are 10 s including fetches, 32 MB of memory, 64 KB of source and 256 KB per fetched body. expiresInSeconds defaults to 300 and is capped at 3600. Secrets belong in credentials. {{ … }} inside source is never interpolated.

Hosted, the same block goes on POST /api-connectors or PUT /connectors/{id}/auth, with {{ secrets.NAME }} instead of env. From the CLI:

# auth.json may say "sourceFile": "./hook.js" instead of inlining "source"
connectable api-connectors auth <connector-id> --auth-file auth.json
connectable api-connectors auth-test <connector-id>   # header names and expiry, never values

The dashboard has the same on the connector's page, under Authentication. Each token fetch made during a call gets its own audit row (kind: auth, tool auth:hook or auth:oauth2_client_credentials).

Serving a REST connector as GraphQL

A dashboard, a script or a front end often wants several resources in one round trip, with types, so an openapi connector can also serve its enabled operations as a GraphQL endpoint:

{
	"connectors": [
		{
			"name": "control",
			"transport": "openapi",
			"specUrl": "https://your-deployment.example/api/v1/openapi.json",
			"headers": { "authorization": "Bearer cmt_…" },
			"include": ["GET /agents", "GET /connectors"],
			"graphqlSurface": true
		}
	]
}
curl -s localhost:3333/graphql/control \
	-H 'authorization: Bearer $CONNECTABLE_API_KEY' \
	-H 'content-type: application/json' \
	-d '{"query":"{ get_agents { agents { id name disabled } } }"}'

The example wraps connectable's own management API (roadmap 8.6) because it is the OpenAPI document nearest to hand. Any document works the same way. Field names are tool names: GET /agents materializes as the tool get_agents, and that one string is what the query selects, what a policy allows or denies, and what the audit row records.

The schema is built by openapi-x-graphql from the same document the tools came from, which is where the response types come from. A tool definition only describes how to build a request, so a surface built from the tool list alone could return nothing better than JSON.

This is a second view of the gateway, not a second way into it:

  • A field is a tool. Every root field resolves through the same Router.callTool path an MCP tools/call takes, so the per-tool policy, both rate-limit tiers, spend caps, approvals, pinning and the audit row all apply, and the audit log names the tool, not "a GraphQL query". Nothing here issues its own HTTP request.
  • The schema is the selection. Operations you did not enable are never built into the schema, and an argument you pinned (?account=acct_123) is removed from the field rather than accepted and silently overridden.

One HTTP request may still select several root fields, and each is a separate governed upstream call, so a document is capped at 10 root fields, and request bodies at 128 KiB. Set CONNECTABLE_GRAPHIQL=1 to serve the GraphiQL IDE on a browser GET of the same path.

There are two switches, and both ship off. CONNECTABLE_GRAPHQL_SURFACE=1 (or startServer({ graphqlSurface: true })) says this deployment answers on /graphql; "graphqlSurface": true on a connector says these operations are safe to expose that way. They answer different questions and belong to different people: the connector flag travels with an exported configuration, while the deployment flag is a property of the process and does not. So importing somebody else's config file cannot open an inbound surface on your gateway. With the deployment flag unset, no schema is built and /graphql/* answers 404, and the gateway logs a line naming any connector that asked, so the setting doesn't quietly do nothing.

Self-hosted only for now. Hosted connectors are configured in the dashboard, which has no card for this yet.

Wrapping a web page

Some upstreams have no API at all: a changelog, a status page, a docs page, a wiki. A web connector points at pages you name and serves each one as a read-only MCP tool that returns the page's text, not its markup.

{
	"connectors": [
		{
			"name": "docs",
			"transport": "web",
			"pages": [
				{
					"tool": "changelog",
					"url": "https://bun.com/blog",
					"selector": "main"
				},
				{
					"tool": "wikipedia",
					"url": "https://en.wikipedia.org/wiki/{title}",
					"params": [{ "name": "title", "description": "Article title" }],
					"selector": "#mw-content-text",
					"maxChars": 8000
				}
			]
		}
	]
}

You declare the pages and the agent picks among them. I didn't ship a single fetch(url) tool because it would hand a model a general-purpose request primitive sitting inside your network with your headers attached, aimed wherever inference decides. Here a {param} may fill a path or query, and the origin is fixed. A template with a placeholder in the host is refused when the config loads, and arguments are percent-encoded and origin-checked before anything is dialed.

HTML is extracted to markdown, so a 300 KB page arrives as a few thousand tokens instead of a context-window denial of service. selector narrows it further (main, #content, article .body); without one, the main content is detected and navigation, headers and footers are dropped. robots.txt is honoured by default; set "respectRobots": false on the connector to override.

JavaScript is not executed. A client-rendered page comes back with a note saying so rather than as a mysterious blank result.

In the dashboard, Connectors → Web page is the same thing with a preview button: paste a URL, try a selector, see exactly what a tool call would return, then save. Pages are editable afterwards under the connector's Pages link.

Credentials

A connector never stores a credential. Its env and headers hold {{ secrets.NAME }}, and the value lives once in the org's secret store, encrypted with CREDENTIAL_ENCRYPTION_KEY, bound by AEAD to the org and the name, and resolved at the last moment before an upstream is dialed.

connectable secrets set STRIPE_API_KEY sk_live_…
connectable connectors create stripe --transport http --url https://mcp.stripe.test/mcp \
  --header 'Authorization=Bearer {{ secrets.STRIPE_API_KEY }}'

What the indirection gets you:

  • One value, one place. Three connectors sharing a vendor key share a row, so rotating it is one write rather than a hunt for every connector that got a copy.
  • Rotation and revocation reach every connector. connectable secrets set changes what every referring connector presents on its next call; connectable secrets delete stops all of them. Revoking a connector is a different act with a different blast radius, and both are available because incidents differ.
  • Exports and API responses carry names. A config bundle, a dashboard list and an audit line all show STRIPE_API_KEY, which is not a secret. There is no endpoint that returns a value and no connector field that accepts one. A reference to a secret that does not exist is a 400, not a connector that silently sends a template upstream.
  • Secrets are write-only by default. A secret can be set by CI and used by the gateway without being readable back, so a leaked deploy token is not a leaked API key.

In the dashboard the credential field on every connector form does this for you: pick an existing secret, or paste a value and it is saved as a named secret on submit. The name is shown and editable before anything is written.

The single-tenant connectors.json path is the exception, and only because it has no database to hold a secret store: values live in the file, which is why that file is for local development and self-hosting rather than a shared deployment.

What travels the gateway

All of MCP, not only tools:

CapabilityThrough the gateway
tools/list, tools/callNamespaced connector__tool. Policy, aliases, pins, approvals, caps, quotas, audit.
outputSchema, structuredContentPassed through unchanged. Which tools declare one, and why the rest do not: output-schemas.md.
resources/*Re-published under connectable://<connector>/<encoded-uri>. Reads are governed.
prompts/list, prompts/getNamespaced connector__prompt, governed on the same path.
sampling/createMessageForwarded from the upstream to the client whose call provoked it. Needs a deployment that holds sessions; see below.
elicitation/createSame.
*/list_changed, resources/updatedForwarded, so a client re-lists instead of trusting a stale one.

A resource URI is rewritten because MCP treats one as opaque: file:///README.md from two connectors is two documents with one identifier. The gateway publishes its own URI carrying the connector, and maps it back on read, the same trade the tool namespace makes. Resource templates keep their expression un-encoded (connectable://docs/file:///{name}.md), since the client expands one before handing it back.

Sampling and elicitation run the other way: the upstream asks the client. MCP correlates such a request with nothing, so the gateway attributes it to the call in flight on that connector, and refuses rather than guesses when that is ambiguous. It can only be ambiguous in single-tenant mode, where one registry is shared across sessions. A client that never declared sampling gets an error naming the capability, not a hang.

Both need a deployment that holds MCP sessions. The gateway on Fly or on Cloudflare Containers does; the Next app's own /mcp route on a serverless host does not (see MCP sessions and where they can be held). This is structural, not a gap to be closed: the client's answer is a second HTTP request, and it has to reach the call still waiting for it, in the same process, the same in-flight transport. A session id is what makes that routable. Sessionless, the reply has nowhere to land, so the gateway refuses the request outright rather than leaving the upstream to wait out its own timeout. Run the gateway on Fly, or anywhere a request can be routed back to the process holding its session, and both work; packages/core/test/stateless.test.ts pins down each half.

Connector scope is enforced on both halves of every capability: a connector the endpoint was not granted contributes no resources or prompts and refuses reads of them. There is no per-resource allow/deny list; that would need a reading of tool policy for URIs that the configuration does not record. See the tier 9 caveats in ROADMAP.md.

The audit log, and why you should not have to take its word for it

Every governed call writes a row: which agent, which tool, the outcome, the latency, and an argument summary redacted per the org's settings. The rows are hash-chained so somebody who doesn't trust whoever runs the gateway can still check them.

Each row carries seq, prev_hash and hash, where

hash(n) = SHA-256( canonical(row n) || hash(n-1) )

Edit a row and its hash no longer matches its contents. Delete one and seq skips. Neither is repairable without rewriting every row after it and the head.

connectable audit chain      # the current head — record this somewhere else
connectable audit verify     # re-derive the whole chain; exits non-zero on any break

Verifying a chain against itself isn't proof of much. A careful rewrite re-links and re-hashes everything downstream and verifies perfectly. What that rewrite cannot do is produce a head you already wrote down. So connectable audit chain exists to be run on a schedule and its output stored somewhere this deployment does not control: a ticket, a CI artifact, a different vendor. That turns "the log is internally consistent" into "the log is the one that existed on Tuesday".

Deleting from the end is caught too, and that's the case that matters most: cutting the newest rows leaves every surviving link perfect, so verification compares the recorded head against the last row present rather than trusting the rows it happens to find.

Retention is a legitimate deletion and is treated as one: prune records the chain state at the edge of the deletion in audit_checkpoint before removing anything, so the surviving rows verify across the gap. Rows removed by anything else leave a gap with no anchor, and verification says gap or unanchored rather than passing. A checkpoint written after the fact does not help mid-chain, because it would need the hash of the row that is already gone.

Rows written before this shipped are excluded and counted, never back-filled; verify reports them as unchained. Assigning hashes retroactively would be manufacturing evidence about a period when none was collected.

What this is not: it does not stop anyone with database access from rewriting the log, and it is not encryption. It makes an undetected rewrite require possession of every head you have ever been shown. If you need a stronger guarantee than that, run the gateway yourself.

It also proves nothing was altered, not that nothing was missed: sequence numbers are assigned when a row is written, so a row dropped under write overload leaves no gap to find. That's deliberate, since a gap would read as tampering when the cause was load, but it means integrity and completeness are two different questions. AuditWriter.droppedCount answers the second one, in health output.

Rate limits

Three tiers, charged narrowest first. They are deliberately separate numbers:

TierKeyed bySet onWhat it is for
ConnectionAPI keyEach key, under Endpoints → KeysThrottle one misbehaving client without touching the others
EndpointEndpointThe endpoint's settingsThe customer quota: fairness and cost control across all its keys
ConnectorConnectorThe connector's settingsSelf-protection: we will not overrun an upstream, whoever is asking

0 on any of them means "no limit at this tier". They compose by narrowing only: every tier that applies is charged, so the effective limit is the lowest one. A connection limit can never widen the endpoint quota.

The connection tier exists because an endpoint's quota is shared by every key issued for it. Without it, the only way to throttle one noisy agent is to throttle all of them or revoke its key, and revoking is an outage for whoever was holding that key.

A call refused by a later tier refunds the ones already charged. The call never reached an upstream, so our protective ceiling or a spend cap shouldn't consume a quota the customer is paying for.

Limits are fleet-wide, not per process. The token buckets shape each machine's flow with no I/O, and a durable per-window budget in rate_lease bounds the fleet's total, so the number in the dashboard is the number enforced regardless of how many machines or function instances are running. Set CONNECTABLE_FLEET_RATE_LIMITS=off to fall back to per-process enforcement, which is exact for single-instance self-hosting and nothing else.

The management API has its own limiter, per credential and per address, which is an abuse ceiling rather than a quota anyone is sold. It answers 429 with Retry-After; see the CONNECTABLE_API_RATE_LIMIT* variables under Configuration.

When a call is refused

Every error carries a machine-readable classification in the JSON-RPC error's data, so an agent can tell "wait" from "never" without parsing English:

{
	"code": -32603,
	"message": "rate limit exceeded for this endpoint (60/min); retry in 12s",
	"data": {
		"category": "quota",
		"code": "rate_limited",
		"retryable": true,
		"detail": { "scope": "endpoint", "retryAfterMs": 12000, "limit": 60 }
	}
}

category is one of policy (a human decided this must not happen), quota (allowed, but not now), auth (a credential needs re-supplying), upstream (the connector failed), gateway (our fault) or request (fixable by asking correctly). The same shape appears on HTTP refusals, so a 401 and a mid-call credential rejection are both "category": "auth".

retryable is conservative: a rate limit is true and says when, a monthly cap is false and says when it resets, and anything unrecognized is gateway/unclassified rather than being dressed up as a decision somebody made.

For a rate_limited refusal, detail.scope names the tier (connection, endpoint or connector), so an agent can tell "I am going too fast" from "this endpoint is busy" from "the upstream is being protected". These travel as JSON-RPC errors because that is what an MCP client is reading; the management API answers the same condition with a real 429 and a Retry-After header, which is what packages/sdk retries on.

Which tool is slow

The audit log answers "what happened to this call"; usage answers "how many calls". Neither tells an operator which tool is making an agent slow or flaky, so the log is folded into one row per (connector, tool) for a window: calls, how many the upstream failed, how many the gateway refused before dialing, and p50 / p95 latency over the successful ones.

connectable analytics tools --window 7d
connectable analytics tools --connector github --window 1h

Percentiles count successful calls only, because a refusal is fast and would flatter the number. Every row on the dashboard's Observe → Tools page opens the audit log narrowed to that tool. The log itself now pages (100 rows at a time, Load more for the next) and accepts ?connector=, ?tool=, ?agentId= and ?endpointId= in the URL, so a link from anywhere lands on exactly the rows it means.

Upstream incidents

When an upstream is down, users blame the gateway. The audit log records every upstream_error, but a log is not a status. It answers "what happened to this call", not "is GitHub down right now, since when, and is it our fault". The gateway keeps that answer as state, one row per connector, and turns every flip into an incident and an event.

connectable health                    # gateway up · 1 upstream down
connectable health incidents          # what has been down, since when, why

Two sources feed it, weighted differently. Real traffic is the truth but noisy: one failed call is not an outage, so a connector goes down only after three consecutive upstream_errors. A probe (connectable connectors status, or the dashboard's button) is deliberate and unambiguous: a failed probe opens an incident at once. Either source closes one on the first success. Refusals (a policy, a limit, a cap, a kill switch) and gateway_error never count against an upstream: the first never reached it, and the second is ours by definition.

GET /api/v1/status reads that state and dials nothing, so a status page can poll it freely; GET /api/v1/incidents is the history. Each transition emits connector.unhealthy or connector.recovered to your webhooks, a newly drifted tool definition emits connector.drift_detected once per distinct change, and a scheduled sweep (a */10 Cron Trigger on Cloudflare; GET /api/v1/cron/anomalies) runs the anomaly detector over every active org and emits anomaly.detected once per new finding. The dashboard's Observe → Health page shows all three.

Outbound webhooks

A webhook tells a URL the org owns when something needs a human: an approval is waiting, a policy refused a call, a kill switch went in. Without one you only find out by looking at the dashboard, which for an approval with a five-minute deadline usually means a timeout.

connectable webhooks create pagerduty https://hooks.example.com/connectable \
  --events approval.requested,approval.expired,kill_switch.engaged
# secret whsec_…   (shown once — store it where the receiver can read it)
connectable webhooks ping $WEBHOOK_ID          # one delivery, right now
connectable webhooks deliveries $WEBHOOK_ID    # what was sent, and what came back

A delivery is a POST with a JSON body:

{
	"id": "evt_…",
	"type": "approval.requested",
	"createdAt": "2026-08-30T12:00:00.000Z",
	"orgId": "…",
	"data": {
		"approvalId": "…",
		"endpointId": "…",
		"connector": "github",
		"tool": "delete_repo",
		"expiresAt": "2026-08-30T12:05:00.000Z"
	}
}

A payload carries names and ids, never arguments and never a credential. The audit log redacts arguments per the org's settings; a webhook payload does not carry them at all. A receiver that needs the argument summary fetches the approval or the audit row over the management API, which is scoped and logged. A webhook endpoint is neither.

Every delivery is signed. The x-connectable-signature header is t=<unix seconds>,v1=<hex> where hex is HMAC-SHA256 over "<t>.<body>" under the webhook's secret. Verify it before reading a field, and reject a t more than a few minutes old:

import { verifyWebhookSignature } from 'connectable.sh';

const ok = verifyWebhookSignature(
	process.env.WEBHOOK_SECRET,
	req.headers.get('x-connectable-signature'),
	rawBody,
);

The secret is generated here, shown once, and stored encrypted under the vault keyring like a named secret, bound by AEAD to the org and the webhook, so a ciphertext copied between tenants fails to decrypt. Rotating it stops the old one verifying immediately.

A delivery is a row before it is a request. Fan-out writes one webhook_delivery per subscribed hook and then attempts it; a non-2xx, a timeout or a refused connection reschedules the row (about 30s, 2m, 10m, 30m, 2h). On Cloudflare the failed attempt puts a message on a queue, delayed by that backoff, and the retry runs when it arrives; an hourly sweep catches anything the queue lost. A self-hosted server runs the sweep on a 30-second timer instead. After six attempts the row is dead, where the dashboard and connectable webhooks redeliver can send it again: the same bytes, a fresh signature. Redirects are not followed; a receiver that has moved is a URL to update, not a hop the SSRF check never saw.

What you can subscribe to (connectable webhooks events serves the same list):

EventWhen
approval.requested, approval.decided, approval.expiredThe human-in-the-loop lifecycle
call.policy_denied, call.rate_limited, call.cap_exceededA call refused by policy, a rate limit, or a spend cap
call.killed, call.quarantined, call.approval_deniedRefused by a kill switch, a drifted description, or a denied approval
kill_switch.engaged, kill_switch.liftedSomebody pulled or released the brake
connector.revokedA connector's credential was revoked
connector.unhealthy, connector.recovered, connector.drift_detectedUpstream health transitions (see Upstream incidents)
anomaly.detectedThe anomaly detector found something new
pingA test event, from the dashboard or webhooks ping

There is no "all events". A webhook created with no subscription receives nothing, for the same reason an API connector has no default operation list: a receiver should never get a payload nobody asked for. call.* events are coalesced per tool per minute within a process, so a rate limit refusing a burst of five hundred calls is one message.

In the dashboard, Observe → Webhooks lists each receiver with the outcome of its last delivery, and a webhook's page carries the delivery log (open a row for the exact body), a test button, pause, rotate and delete. A hosted deployment refuses loopback and private-network receiver URLs on the same rule it applies to connectors.

Connecting a client

The dashboard generates a copy-paste snippet per client under Connect a client. By hand:

claude mcp add --transport http connectable http://localhost:3333/mcp \
  --header "Authorization: Bearer $CONNECTABLE_API_KEY"

# Codex reads the key from the environment
codex mcp add connectable --url http://localhost:3333/mcp --bearer-token-env-var CONNECTABLE_API_KEY

opencode (opencode.json, "type": "remote") and Windsurf (serverUrl) snippets are generated alongside the others.

For Cursor (.cursor/mcp.json) or Claude Desktop (claude_desktop_config.json):

{
	"mcpServers": {
		"connectable": {
			"type": "http",
			"url": "http://localhost:3333/mcp",
			"headers": { "Authorization": "Bearer <your-api-key>" }
		}
	}
}

Telling the agent what's where

An agent picks tools by their names and descriptions, so db1__query tells it nothing about what db1 holds. Instructions fix that. The gateway sends them as the server's instructions in the MCP initialize result, and Claude Code puts them in the model's context.

Self-hosted, add a top-level instructions string to connectors.json:

{
	"instructions": "Orders are in lake (DuckDB, one Parquet file per day under ~/data/orders). Support guides are in notes. Never write to bucket.",
	"connectors": [ ... ]
}

Hosted, they're set per endpoint: Endpoints → (endpoint) → Agent instructions in the dashboard, or from the CLI:

connectable endpoints update <endpoint-id> --instructions-file ./agent.md
connectable endpoints update <endpoint-id> --instructions ""   # clear

The limit is 8,000 characters. Instructions are only sent at initialize, so an edit reaches new sessions and an open session keeps the old text. MCP has no notification for this. There's no org-wide default yet, so an org with several endpoints sets the text on each.

Playbooks: prompts and reference docs

Instructions are always in context. Playbooks are loaded on demand. A prompt shows up in the client's prompt picker. A resource is a document the agent reads when it needs it, at connectable://endpoint/<name>:

{
	"prompts": [
		{
			"name": "weekly_sales",
			"description": "Revenue by region for one week",
			"arguments": [{ "name": "week", "required": true }],
			"template": "Sum revenue by region in lake for {{week}}. Read connectable://endpoint/orders-schema first."
		}
	],
	"resources": [{ "name": "orders-schema", "text": "# Orders\n- order_id …" }],
	"connectors": [ ... ]
}

They sit next to any prompts and resources the upstream servers expose. Upstream ones are always connector__name, and playbook names may not contain __, so the two can't collide. Hosted, set them per endpoint under Playbooks in the dashboard or with connectable endpoints update <id> --playbooks-file playbooks.json (a resource there may say "textFile": "./orders.md"). Up to 50 of each. Reads are audited, and a stopped agent gets nothing. Like instructions, an edit reaches new sessions.

Connector instructions (on the SQL, S3 and filesystem drivers) are separate. They're appended to that one connector's tool descriptions. Use them for details like column meanings, and use endpoint instructions for the map of what lives where.

Finding things in the dashboard

F, or ⌘K, opens a palette over three kinds of result: the pages, the new-thing routes, and the connectors, endpoints, agents and tool profiles this workspace actually has, each going to its own settings screen. It is the only way to reach a page whose navigation entry is hidden by a feature flag, and it means knowing a connector is called stripe is enough to find it without first remembering whether it is a connector or an endpoint.

The workspace half is read on first open, never on page load. Both shortcuts stand down while the caret is in a field.

Everything here is an API call

The dashboard has no private routes. Every screen calls /api/v1, the same versioned surface a script, the SDK or connectable calls, with the same scopes enforced, and each screen says so: a Do this with the API panel at the bottom carries the exact calls that screen's controls make, in curl, TypeScript and CLI form.

Do this with the API   Everything on this page is 9 calls.        ▴

  curl   TypeScript   CLI                     Create a token   Full reference

  GET List connectors   GET Probe every connector   POST Add an MCP connector
  PATCH Change the rate limit   GET Review pinned tools and drift
  POST Approve the current tool definitions   POST Revoke a credential
  DELETE Restore a revoked connector   DELETE Delete a connector

  POST /api/v1/connectors   connectors:write

  export CONNECTABLE_TOKEN=cmt_…

  curl -X POST https://app.example.com/api/v1/connectors \
    -H "Authorization: Bearer $CONNECTABLE_TOKEN" \
    -H "content-type: application/json" \
    -d '{ "name": "github", "transport": "http", … }'

The panel lists the calls as an index and prints one of them. It's open by default, since most people never expand a disclosure triangle. Printing all nine put 1,700px of curl under 350px of connectors, so the index shows every title and verb at a glance and gives the printed snippet the panel's full width. Verbs are toned: writes stand out, reads stay neutral.

Closing it, and which of the three forms to show, is remembered across screens.

The snippets live in packages/ui/lib/api-snippets.ts and are checked against the API: test/api-snippets.test.ts looks every method and path up in the generated OpenAPI document, validates each example body against that route's request schema, and rejects a query parameter the route does not declare. The scope on each row is not written down anywhere. It comes from scopeForRequest, the function the API's own middleware calls, so a snippet cannot understate what a token needs.

Two companion suites keep the header accurate. One walks the route tree and fails if a dashboard page ships without a panel; the other reads what each screen actually fetches, following the shared lib/ helpers it calls through, and fails if a call has no snippet, so everything on this page is N calls stays exact.

Two things are deliberately not offered as automation: minting management tokens and the playground. Both are session-only, and their snippets say so instead of showing an SDK call that would answer 403 forever.

CLI

Every command that acts on a deployment (connectable <resource> <action>, export/import, bootstrap, link, the TUI) talks to the management API over HTTP and needs only a URL and a token, so it works against a hosted deployment, and from CI. No client command opens the database. Only serve and data-plane do, because they are the server, and they exist only in the self-host binary; check, probe, tunnel and import-mcp work on local files (design-cli-over-api.md, design-published-cli.md).

The terminal UI: connectable

Run connectable with no arguments in a terminal (or connectable ui) for an interactive UI: a live approvals queue (a/d), a connectors board with probes and pins, session replay and the audit tail, and a first-run wizard that signs you in, imports the MCP servers you already use and prints the snippet for your MCP client. Production contexts get a red header. Pipes, CI and every command with arguments are unchanged. See the sdk README and design-tui.md.

Remote management: connectable <resource> <action>

connectable login --url <url>                 Approve a code in the browser; this machine gets a token.
connectable login --url <url> --token cmt_…   Store a deployment and credential.
connectable connectors                        A resource with no action lists it.
connectable endpoints create --name prod --connectors c1,c2
connectable audit list --outcome policy_denied --limit 20
connectable audit verify                      # exits non-zero if the log has changed
connectable deployments create prod-gateway   # mint a token for a data plane you run
connectable kill engage --scope agent --subject <id> --reason "runaway loop"
connectable api call POST /api/v1/agents --data '{"name":"runner"}'

Backed by packages/sdk, a typed client for /api/v1 whose client entry has zero runtime dependencies (the TUI's React and Ink load only when it opens). --json prints the API response verbatim and is the interface scripts should use; the human tables are not. Irreversible operations (an org kill, audit prune, deleting a secret, overwriting import, SSO enforcement) require --yes. Exit codes: 0 success, 1 failure, 2 usage.

Tokens are minted by a signed-in human in the dashboard: /api/tokens refuses management tokens, so a token can never issue itself a broader one. See packages/sdk/README.md.

Local and self-host operations: connectable check|probe|...

connectable check [file]              Validate a connectors.json. Exits non-zero
                                      on invalid config, so CI can gate on it.
connectable probe [file]              Dial every connector and report tool counts.
connectable tunnel <file> [name]      Expose a local connector over HTTP.
connectable export [--out <file>]     Alias of `config export` — over the API.
connectable import <file>             Alias of `config import` — over the API.
connectable bridge [file]             Serve the "apps" in [file] to web pages on
                                      127.0.0.1:4317 (see the app bridge, below).

The self-host binary adds three more, which the npm package leaves out:

connectable serve [file]              Start the gateway. Defaults to
                                      ./connectors.json and port 3333.
connectable keygen                    Generate a deployment's Ed25519 attestation key.
connectable data-plane --url <url>    Run as a customer-hosted data plane (below).

export writes a config bundle: connectors, endpoints, tool policies and aliases. It never contains a credential value. A connector stores {{ secrets.NAME }} rather than a token, so the bundle carries the name and the import result lists exactly which secrets to create on the other side. Create them under the same names and the connectors work with nothing else changed.

Running the gateway yourself, with the dashboard hosted

The hosted gateway sees your traffic. It has to: terminating the connection, decrypting the vendor credential, dialing the upstream and reading the result is what applying policy and audit is. No key arrangement changes that while we are the ones doing the work, and encryption at rest doesn't help here: it protects a stolen database backup, not you from us.

So you can run the gateway instead, and keep the dashboard.

# On the control plane, once:
connectable deployments create prod-gateway        # prints a cbd_… token, shown once

# On your own machine, with the self-host binary (One binary, below):
connectable keygen > deployment.key        # Ed25519, never leaves this host
export CONNECTABLE_DEPLOYMENT_TOKEN=cbd_…
connectable data-plane --url https://app.connectable.sh --key deployment.key

The first run provisions everything it needs locally: a service identity for the process itself, and the organization under the control plane's own id. That id is not a detail you can work around. An audit row is hashed over its org_id, so rows chained under a locally-minted one produce a chain the far side must refuse and cannot repair, because rewriting the id invalidates every hash. A data plane's organization is not a new tenant, it is the same tenant seen from the other side. Pointing one at a local org with a different id fails at startup rather than at the first push.

There is no --user. Every other command here acts for a person; this one runs unattended, and the only actor is the process.

What crosses the boundary, in each direction:

DirectionCarriesNever carries
Down (config)Connectors, endpoints, tool policies, rate limits, approval and pinning settingsAny credential; the bundle holds {{ secrets.NAME }}, never a value
Up (audit)Chained audit rows, redacted per your own argumentMode, plus a signed headTool arguments or results beyond what your redaction setting already allows

Credentials live in the deployment's own store under its own CREDENTIAL_ENCRYPTION_KEY. The control plane has no copy of either half, because a config bundle has never contained a secret, for reasons that predate this feature.

You can check what it sends. The deployment talks to your upstreams and to exactly one control-plane origin. Put it behind your own egress proxy and watch.

The control plane can't write your audit log. connectable keygen produces an Ed25519 key pair; the private half stays on your machine and signs every chain head the deployment pushes. The control plane stores the signature and verifies it, but cannot produce one, so a row we invented would fail the check, and an attested checkpoint is proof the head came from your process rather than ours. The key is write-once: replacing it would let a deployment re-sign history it had already attested to, so rotation means a new deployment and the old attestations stay checkable.

A control-plane outage is not your outage. Sync failures are logged and retried; the deployment already has its configuration and its secrets and needs nothing from us to serve a tool call.

What this does not give you: proof of which binary is running. The architecture removes us from the payload path, and an operator with root on the control plane still cannot read your traffic, but "the code you audited is the code in that container" is a build-attestation problem this does not solve.

Databases, storage and files

Connectors are pluggable through drivers; connectable drivers lists them. Built in: postgres (and Neon, Supabase, RDS…), mysql (MariaDB, PlanetScale…), sqlite, duckdb (Parquet/CSV/JSON in directories you allow), duckdb-s3 (the same over one bucket and prefix, read-only, runs hosted), s3 (AWS, R2, B2, RustFS, MinIO), filesystem and agent. Every SQL driver serves the same tools (list_tables, describe_table, query, and execute only with allowWrites) with the same guarantees: one read-only statement per call, run inside a read-only transaction or connection, a statement timeout, a row cap that is reported rather than silent, optional schema/table scoping, and a schema://tables resource. Driver tools that return data declare an outputSchema, so a client gets typed structuredContent (output-schemas.md).

{
	"connectors": [
		{
			"name": "warehouse",
			"transport": "driver",
			"driver": "postgres",
			"options": {
				"connectionString": "{{ env.WAREHOUSE_URL }}",
				"schemas": ["analytics"],
				"maxRows": 200
			}
		},
		{
			"name": "exports",
			"transport": "driver",
			"driver": "s3",
			"options": {
				"bucket": "acme-exports",
				"prefix": "reports/",
				"accessKeyId": "{{ env.AWS_ACCESS_KEY_ID }}",
				"secretAccessKey": "{{ env.AWS_SECRET_ACCESS_KEY }}"
			}
		}
	]
}

In a self-hosted file, {{ env.NAME }} reads the environment; in the dashboard, credentials are org secrets referenced as {{ secrets.NAME }} at any leaf of options. Any resolved credential that an upstream echoes back (in an error, a result, URL-encoded or base64'd) is replaced with [secret:NAME] before a model sees it.

Write your own driver with defineDriver and load it with "plugins": ["./my-driver.ts"] in connectors.json (self-hosted only; a hosted tenant can choose drivers, never supply code). local drivers (files, SQLite, DuckDB, agents) are refused wherever stdio is; a hosted gateway reaches them through a Link.

Local coding agents as tools

The agent driver turns Claude Code, Codex, opencode or any CLI agent running in a directory into one tool, run_task, returning the answer, session id (pass it back as resume_session), cost and changed files:

{
	"name": "claude-code",
	"transport": "driver",
	"driver": "agent",
	"options": { "runner": "claude", "cwd": "~/code/app", "permission": "plan", "maxBudgetUsd": 2 }
}

permission is plan (read-only, annotated as such) or edit (annotated destructive, so a write approval policy gates it). There is no bypass mode.

A Link serves connectors on your machine to a hosted deployment over an outbound connection, with no tunnel and no open port:

connectable link pair --url https://connectable.sh   # prints a code; approve it in the dashboard
connectable link ./connectable.link.json              # serve what that file describes

connectable.link.json has the same shape as connectors.json: stdio MCP servers, filesystem, sqlite, duckdb, agent, Postgres on localhost. The device mints its own token and sends only its hash; approval binds it to your org. In the dashboard (Links), add any advertised connector to the org and scope it to endpoints like any other. The local file is final: the gateway can call only what it lists. Revoking a link stops its token on the next request.

Web pages talking to your agent: the app bridge

The bridge lets a web app hand a prompt to the agent on the user's machine and stream the answer back, and lets that agent call tools the open page exposes. It works on loopback for a page on localhost, and through a paired Link for a deployed app. Design and wire format: design-app-agent-bridge.md.

List the apps in connectable.link.json, next to connectors:

{
	"connectors": [],
	"apps": [
		{ "id": "ohmyascii", "mode": "text", "origins": ["https://ohmyascii.example"] },
		{ "id": "docs-bot", "mode": "agent", "cwd": "~/code/docs", "origins": ["http://localhost:*"] }
	]
}
connectable bridge ./connectable.link.json
# bridge on http://127.0.0.1:4317 for ohmyascii (text), docs-bot (agent)
# page tools: claude mcp add --transport http bridge http://127.0.0.1:4317/mcp

It binds 127.0.0.1:4317, or the next free port up to 4326, and never takes a port that's in use. When this machine is paired (connectable link pair) the same process also answers the hosted relay; --no-link keeps it local.

  • mode: "text" runs Claude Code with no tools, no MCP servers and no settings, in the OS temp dir, with the page's system prompt. agent runs the agent driver's plan / edit preset in cwd; the page can't replace its system prompt.
  • A page is authorized by Origin, and Host must be loopback. Loopback pages reach text apps without being listed. Agent apps accept only the origins they list.
  • The runner, mode, directory and budget come from this file. A page sends a prompt, a session id and (text mode) a system prompt.

The page side is connectable.sh/client, which has no dependencies of its own:

import { connect } from 'connectable.sh/client';

const bridge = await connect({ app: 'ohmyascii', relay: '/api/bridge' });
for await (const event of bridge!.ask('a lighthouse in a storm', { system })) {
	if (event.type === 'text') render(event.delta);
}
await bridge!.expose([{ name: 'get_scene', handler: () => scene }]);

For a deployed app, createBridgeProxy from connectable.sh/client/proxy is the app backend's route: it forwards /api/bridge/* to /api/v1/links/<id>/bridge/v1/* with a management token (connectors:write), and the deployment relays it to the device as a bridge job.

Remote MCP servers with OAuth

http connectors speak the MCP authorization spec: on the Connectors page, Connect runs discovery, dynamic client registration and PKCE, and stores the grant encrypted per connector; refreshes happen at dial time. Servers that only speak the legacy HTTP+SSE transport are detected automatically (protocol: auto).

Importing the servers you already have

connectable import-mcp claude-desktop   # or claude-code | cursor | windsurf | vscode | codex | <path>

Writes connectors.json, lifting credentials into .env.connectable (mode 0600) as {{ env.NAME }}. The dashboard's Import a config does the same into org secrets, and offers stdio servers as a Link snippet.

One binary

bun run build:binary
DATABASE_DRIVER=sqlite DATABASE_PATH=./connectable.db \
BETTER_AUTH_SECRET=… CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -base64 32) \
  ./dist/connectable serve --multi-tenant --migrate

No browser needed at any point: connectable bootstrap --url … signs up, creates the org and mints a token over the API; links grant pre-authorizes devices; oauth start --wait finishes on any device. See USAGE.md, Fully headless.

One process serves /mcp/<slug>, the management API at /api/v1 (OpenAPI at /api/v1/openapi.json), sign-in at /api/auth, and the link relay. Migrations are embedded. Without --multi-tenant, serve is the file-backed gateway as before.

Reaching a local MCP server from a hosted gateway

Prefer a Link. The older route still works: a hosted deployment refuses stdio connectors (accepting an arbitrary command from an untrusted signup is remote code execution), so run the tunnel locally and publish it yourself:

connectable tunnel ./connectors.json my-server --port 4400

It holds your server as a child process and exposes it over a loopback, token-authenticated HTTP listener, then prints the connector config to paste in. To reach a hosted gateway, publish that port with your own tunnel (ngrok, cloudflared, Tailscale Funnel) and use the resulting public HTTPS URL. The gateway then treats it as an ordinary HTTP connector, subject to every usual check.

The connection is outbound from your machine on purpose. A gateway that dialed into private addresses on request would be server-side request forgery.

Configuration

VariablePurpose
DATABASE_DRIVERsqlite (default) or postgres
DATABASE_PATHSQLite file. Defaults to ./connectable.db
DATABASE_URLPostgres connection string
DATABASE_POOL_MAXConnections per process. Default 10, or 4 on serverless
DATABASE_POOL_IDLE_TIMEOUTSeconds before an idle connection is closed. Default 60, or 20
DATABASE_CONNECT_TIMEOUTSeconds to wait for a connection. Default 10
DATABASE_PREPAREfalse behind a transaction-mode pooler. Auto-detected
CREDENTIAL_ENCRYPTION_KEYbase64 256-bit key encrypting the secret store. Required to store any credential
CONNECTABLE_API_KEYShared bearer token (single-tenant mode)
CONNECTABLE_SANDBOXhosted to refuse stdio and private-network targets
BETTER_AUTH_SECRETSession signing secret (dashboard)
CRON_SECRETBearer token for the hosted cron routes. Required for them to run
STRIPE_SECRET_KEYRestricted key (rk_) for billing. Unset disables billing
STRIPE_WEBHOOK_SECRETSigning secret for /api/v1/webhooks/stripe
CONNECTABLE_SESSION_IDLE_MSIdle session lifetime before upstreams are closed. Default 5 min
CONNECTABLE_TOOL_LIST_CACHE_MStools/list cache TTL. Default 30 s
CONNECTABLE_RESPONSE_CACHE_MSRead-only response cache TTL. Off by default; see below
CONNECTABLE_CACHE_MAX_ENTRIESCap on cached tool lists / responses. Default 1000
CONNECTABLE_FLEET_RATE_LIMITSoff to enforce rate limits per process instead of fleet-wide
CONNECTABLE_API_RATE_LIMITManagement-API calls per minute per credential. Default 1000, 0 disables
CONNECTABLE_API_RATE_LIMIT_BURSTBurst above that sustained rate. Default 300
CONNECTABLE_API_RATE_LIMIT_IPManagement-API calls per minute per address. Default 600, 0 disables
CONNECTABLE_API_RATE_LIMIT_IP_BURSTBurst above that sustained rate. Default 200
CONNECTABLE_METRICS_TOKENBearer token for /metrics. Unset means the route 404s; see below
FLAG_SSOFeature flag: single sign-on. Off by default; see below
FLAG_ABUSE_SIGNALSFeature flag: abuse signals. Off by default; see below
FLAGS_SECRETbase64url 256-bit key enabling per-browser flag overrides

Feature flags

The dashboard's flags are defined in packages/ui/lib/flags.ts using the Flags SDK (flags/next). Two ship off:

FlagVariableCovers
ssoFLAG_SSOThe SSO page, /sso*, the public /sso/resolve probe, and password-login enforcement
abuse-signalsFLAG_ABUSE_SIGNALSThe abuse-signal feed and /abuse-signals

Set either to true (or 1) to switch it on; anything else, including an unset variable, means off.

A flag is applied at every door to its feature, not just the visible one: the sidebar entry disappears, the page answers 404, its API routes answer 404 to an authenticated caller, and the paths drop out of /api/v1/openapi.json, so an SDK generated from a deployment's own spec never compiles against a route that deployment refuses to serve. The API gate sits behind the authentication challenge, so an anonymous request still gets 401 and cannot use the status to fingerprint which features a deployment runs.

Switching sso off also stops enforcement at sign-in. That is deliberate: an org that had already turned on "require SSO" would otherwise keep refusing password sign-in for its members with the admin page that unsets it gone.

Flags are evaluated per request, so setting FLAGS_SECRET lets the Vercel Toolbar override one for a single browser without a redeploy. /.well-known/vercel/flags publishes the definitions and answers 401 while that variable is unset.

The gateway process has one of its own, and it is a plain environment read because there is no Next request scope to evaluate a Flags SDK flag in:

VariableDefaultCovers
CONNECTABLE_GRAPHQL_SURFACEoffEvery /graphql/<connector> route (see "Serving a REST connector as GraphQL")

Same reading rule (1 or true switches it on, anything else including unset means off) and the same both-doors discipline: with it unset no schema is built, so there is no route to hide.

Response caching

CONNECTABLE_RESPONSE_CACHE_MS is off unless you set it, and that is deliberate. A cached tool result is a correctness risk in a way a cached tool list is not. Only tools the upstream annotates as both readOnlyHint and idempotentHint are ever cached, and an isError result never is. But those annotations are the upstream's own claim about itself, so switching this on is a judgement about which upstreams you trust to be honest. Start with CONNECTABLE_TOOL_LIST_CACHE_MS, which cuts real upstream fan-out with none of that exposure.

Capacity metrics

GET /metrics serves Prometheus text describing what this process is currently holding. It is served by both the Bun gateway and the hosted Next app, and it needs CONNECTABLE_METRICS_TOKEN:

curl -s localhost:3333/metrics -H "authorization: Bearer $CONNECTABLE_METRICS_TOKEN"
connectable_sessions_active 148
connectable_upstream_connections 302
connectable_stdio_children 11
connectable_process_rss_bytes 733003776
connectable_sources_connected{transport="http"} 291

Unset token means the route does not exist. It answers 404 rather than 401, the same rule the flagged API routes follow: a deployment that never opted in is indistinguishable from a build without the endpoint. That matters because this is the public listener, and while these numbers name no tenant, they are a live read on how much traffic a deployment carries.

Nothing here is labelled by org, endpoint or connector. Partly for the reason /healthz gives for reporting no connector names, and partly because one series per tenant per connector is how a Prometheus instance falls over.

Why this exists rather than scaling on requests. A gateway machine is bound by sockets and child processes, not CPU or request concurrency. An agent holds a session open for minutes and calls a tool occasionally, so hundreds of sessions can present a handful of in-flight requests. Fly's auto_start_machines reads request concurrency, which means it would decline to add a machine that is already at its memory ceiling. Scale on max(sessions/target, connections/target, stdio_children/target) instead, and measure the targets against RSS under real load rather than guessing them.

connectable_upstream_connections counts only stdio and http sources. openapi, graphql and web sources report themselves connected whenever they are healthy (true, because there is no handle to hold), but they cost nothing while idle, and counting them would inflate the exact number the autoscaler divides. The per-transport gauges still mirror each source's own health.

Audit-log retention

retention_days is per org (90 by default; 0 keeps forever), and rows are deleted in bounded chunks so a sweep never blocks the audit writes running alongside it.

Self-hosted: startServer runs the sweep every six hours in-process. Set auditRetentionIntervalMs: 0 to disable it and prune out of band.

Hosted: a Worker has no long-lived process to hold that timer, so a Cron Trigger runs the sweep every six hours through /api/v1/cron/audit-retention (see Scheduled jobs). That route requires CRON_SECRET as a bearer token and refuses to run if that variable is unset, so a missing secret fails closed rather than leaving a public endpoint that deletes audit history.

Billing

Optional. Unset STRIPE_SECRET_KEY and nothing here runs. Plans still enforce entitlements; they just are not sold. Everything provider-specific lives in packages/ui/lib/billing/stripe.ts; packages/core never imports an SDK and never computes money.

Plans are a flat monthly base with seats above it, not pay-as-you-go:

PlanPriceSeatsConnectors, endpoints, agents, callsRetentionstdio
Free$015 connectors, 2 endpoints, unlimited calls and agents1 dayno
Team$50/mo, 5 seats included, then $10/seatunlimitedunlimited180 daysyes
EnterpriseSame list price, negotiated termsunlimitedunlimitedunlimitedyes

Calls are unlimited and metered for reconciliation only, never charged for (see 6.2 in the roadmap), so there is no meter feed to Stripe. Connectors and agents are uncapped on paid plans deliberately: charging for connectors taxes the one action the product exists to encourage, and capping agents makes tenants share one identity across a fleet, which destroys the per-agent attribution the audit log exists to provide.

Setting it up:

  1. In Stripe, create one Product per plan (a shared product would make every invoice line read the same name) and one recurring Price on each, with graduated tiers: first tier up_to 5 at a flat $50, second tier up_to inf at $10 per unit. One tiered price rather than a base line item plus a seat line item, because the webhook's only route from a payment back to an entitlement is price → plan, and two prices for one plan would make that lookup half-match.
  2. Map each price onto a plan key:

    connectable plans set-price team price_1234
  3. Point a webhook endpoint at https://<host>/api/v1/webhooks/stripe, subscribed to checkout.session.completed and customer.subscription.*. Put its signing secret in STRIPE_WEBHOOK_SECRET.

The subscription's quantity is the org's seat count, so it is a number Stripe has to be told: checkout opens at the org's real headcount, and syncSeats pushes every later membership change with proration. That call is best-effort, since a Stripe outage must not fail an invitation that has already been accepted here, so a provider failure leaves a logged seat-count drift rather than a broken team page.

Because seats are only ever pushed, nothing corrects that drift on its own: no webhook carries a seat count back. GET /api/v1/cron/sync-seats re-pushes every billed org's quantity daily (same CRON_SECRET), which is early enough that a correction lands as a proration on the coming invoice rather than as a support conversation about one already sent. It answers {"skipped": true} on a deployment with no STRIPE_SECRET_KEY, and each correction it makes is logged: a sweep that keeps finding work means the push path is failing.

price_cents, base_price_cents and included_seats in the plan catalog are informational: they are what the dashboard renders, never what anyone is charged. The Stripe price is the only authority on an amount, which is why the plan page states the offer ("$50/mo — includes 5 seats, then $10/seat") instead of multiplying seats into a total that would eventually disagree with an invoice.

Use a restricted key (rk_) with write access to Checkout Sessions, Customers, Subscriptions and the Billing Portal, and nothing else. A compromised key then cannot issue refunds or read the balance. Set both with wrangler secret put, which is write-only.

What the webhook is allowed to do is bounded deliberately: a subscription on a price no plan maps to changes no entitlements and is logged instead, since the alternative is granting a plan for a payment matched to nothing. And no payment problem short of a real cancellation becomes canceled. That status drops an org to free, and doing it over a failed card would delete a paying tenant's access to their own audit log mid-dispute.

billing_usage_report is a reconciliation record, not a meter feed: one row per closed month per billed org, written by GET /api/v1/cron/close-billing-periods (monthly, same CRON_SECRET). It answers "what did this tenant actually consume in the month we charged them for", which is the question behind every plan change and every dispute.

Development

bun install
bun run build      # bundle
bun run db:migrate # apply migrations
bun test
bun run lint
bun run type-check

db:migrate reads packages/ui/.env.local for DATABASE_DRIVER/DATABASE_URL, the same file the dashboard and bun run dev's gateway resolve their database from, so it migrates the database the app actually reads and prints which one (host:port/name, never the URL). Migrating something else instead is the one failure here that reports success: the migration stays pending and surfaces later as a query against a column that does not exist. Override for a one-off with an explicit DATABASE_DRIVER=sqlite bun run db:migrate; values already in the environment win over the file.

Both deploy paths migrate before anything is promoted, so a schema that cannot be applied never gets code that assumes it: the Cloudflare deploy workflow migrates before either wrangler deploy, and both Fly apps run it as release_command, which Fly executes from the new image and waits on before releasing a machine. The trade that makes this safe is that migrations stay additive (new tables and nullable columns, no drops or renames in the release that starts using them), so the previous image still runs against the new schema and a rollback remains a rollback.

Deploying to Cloudflare

deploy/cloudflare/ runs the same Dockerfile Fly runs, as Cloudflare Containers, behind a Worker that only picks the instance. The reasoning is in design-cloudflare.md.

cd deploy/cloudflare
bun install
wrangler kv namespace create SLUG_ORGS      # put the id in wrangler.jsonc
# Same secrets as fly.toml; the Worker hands them to every container.
wrangler secret put DATABASE_URL            # and DATABASE_DRIVER, DATABASE_POOL_MAX,
wrangler secret put CREDENTIAL_ENCRYPTION_KEY   # CAPABILITY_TOKEN_PRIVATE_KEY, ...
bun run scripts/db-migrate.ts               # from the repo root; there's no release_command
wrangler deploy

ROUTING in wrangler.jsonc picks one of two layouts:

  • org (the default): one container per org. Clients connect to /o/<org>/mcp/<endpoint-slug> and links poll /o/<org>/api/v1/links/.... The Worker strips the prefix and routes to the container named after the org. A container only serves its own org: another org's key, capability token or link token gets a 404 there, the same as an endpoint that doesn't exist.
  • pool: POOL_SIZE shared containers gw-0 .. gw-{N-1}. Session ids carry the instance (<uuid>~gw-1) and the Worker sends follow-ups back to it.

In org mode a bare /mcp/<endpoint-slug> URL gets a 308 to the prefixed one. The Worker looks the org up in KV, then asks a _directory container on a miss. The MCP TypeScript SDK (1.30) follows a 308 on POST and keeps the Authorization header, since the redirect stays on the same origin. I haven't checked every client, so give new configs the prefixed URL and treat the redirect as a fallback for old ones. A client that refuses to follow it needs its URL changed by hand.

Containers don't replay: fly-replay is only sent when FLY_MACHINE_ID is set. A container that lost a session (sleep, deploy) serves the next request as a fresh session, same as a restarted Fly machine. SLEEP_AFTER (24h) has to outlast your idlest agent.

Pushes to main deploy it once CI passes (.github/workflows/deploy-cloudflare.yml): migrate, then wrangler deploy, then a /healthz check. It needs the CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and DATABASE_URL repo secrets, and skips with a warning while the token is missing. The Worker's own secrets aren't touched by it.

A running container outlives wrangler deploy and keeps the env it started with. The Gateway class hashes the env it would start with and restarts the container on its next request when that hash changed, so a rotated secret takes effect without touching the containers by hand. Bump ENV_REVISION in src/worker.ts to force a restart of every container.

The containers share CREDENTIAL_ENCRYPTION_KEY with the dashboard; with a different key they can't decrypt the secrets the dashboard stored. A key revoked on the dashboard reaches them through the org's governance epoch, which the gateway reads before reusing a cached resolution.

wrangler dev builds the image locally, so it needs Docker and network access to Docker Hub from Docker's VM. Put the secrets in deploy/cloudflare/.dev.vars (gitignored).

Webhook retries from the containers go to the dashboard's queue (next section) when the gateway Worker has CONNECTABLE_WEBHOOK_RETRY_URL (set in wrangler.jsonc) and the CRON_SECRET secret, the same value as the dashboard's. Without the secret, each container polls for due deliveries every 30 seconds as it did before.

Deploying the dashboard to Cloudflare

packages/ui builds into a Worker with OpenNext. The reasoning is in design-cloudflare-dashboard.md.

cd packages/ui
wrangler queues create connectable-webhook-retries
wrangler hyperdrive create connectable-production --connection-string="postgres://..."   # id goes in wrangler.jsonc
wrangler secret put BETTER_AUTH_SECRET          # and BETTER_AUTH_URL, CREDENTIAL_ENCRYPTION_KEY,
wrangler secret put CRON_SECRET                 # CONNECTABLE_SANDBOX=hosted, STRIPE_* if billing is on
bun run build:cloudflare
wrangler deploy

With the HYPERDRIVE binding the Worker ignores DATABASE_URL and connects through Hyperdrive, which needs a database that accepts TLS. Point Hyperdrive at the direct endpoint, not a pooler: it pools itself. Without the binding, set DATABASE_URL and every request opens its own connection, which works and is slow.

A Worker can't use a socket opened by another request, so on Workers each request gets its own Postgres client (packages/core/src/db/request-scope.ts). That's automatic; DATABASE_CONNECTIONS=per-request forces it elsewhere.

The Worker forwards /mcp/* and /o/* to the gateway Worker through the GATEWAY service binding, so clients keep using https://connectable.sh/mcp/<slug>. Everything else goes to Next.

The Cloudflare build is Postgres-only: bun:sqlite, better-sqlite3 and typescript are aliased to a stub (packages/ui/cloudflare/unavailable.js). OpenNext would also copy your .env* files into the Worker as defaults, so build:cloudflare empties that file after bundling.

bun run preview:cloudflare -- --env staging runs the built Worker locally under workerd against DATABASE_URL from packages/ui/.dev.vars. Add CONNECTABLE_TRUSTED_ORIGINS=http://localhost:*,http://127.0.0.1:* there: wrangler dev runs with NODE_ENV=production, so localhost isn't trusted by default. The production config has Hyperdrive, which wrangler dev won't run without CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE.

The deploy workflow builds and deploys it after the gateway, once the database is migrated, then checks /login answers.

connectable.sh is the Worker's custom domain (routes in wrangler.jsonc), on a Cloudflare zone. workers_dev stays on because the gateway's retry URL and the CI health check use the workers.dev host.

Scheduled jobs

Five sweeps, all defined in packages/server/src/lib/jobs.ts:

JobSchedule (UTC)What it does
webhook-deliverieshourlyAttempts due webhook deliveries; the backstop for the queue
anomaliesevery 10 minAnomaly detection over orgs with traffic in the last hour
audit-retentionevery 6 hDeletes audit rows past each org's retention_days
sync-seatsdaily 03:00Re-pushes seat counts to Stripe
close-billing-periods1st, 01:00Records last month's usage per billed org

On Cloudflare they're Cron Triggers in packages/ui/wrangler.jsonc, which must list the same expressions as JOB_SCHEDULE (test/jobs.test.ts checks). On a long-lived next start, CONNECTABLE_SCHEDULER=in-process runs them on timers. Anywhere, POST /api/v1/cron/<job> with Authorization: Bearer $CRON_SECRET runs one by hand. All five are safe to run twice.

Self-hosting the dashboard on Fly

Nothing above is required to self-host. fly.dashboard.toml runs the dashboard as next start beside the gateway from fly.toml, both on the same Postgres:

fly launch --no-deploy -c fly.dashboard.toml      # rename `app` first
fly secrets set -c fly.dashboard.toml DATABASE_DRIVER=postgres DATABASE_URL=... \
  BETTER_AUTH_SECRET=... BETTER_AUTH_URL=https://<host> CREDENTIAL_ENCRYPTION_KEY=<the gateway's>
fly deploy -c fly.dashboard.toml --build-arg NEXT_PUBLIC_APP_URL=https://<host>

It builds Dockerfile.dashboard (with its own .dockerignore, since the gateway image leaves packages/ui out), migrates as release_command, and runs the scheduled jobs in-process. Webhook retries come from the 30-second sweep in both the dashboard and the gateway, with no queue involved.

packages/core is the gateway, its data layer, and the connectable CLI's database commands; packages/ui is the Next.js dashboard and management API; packages/sdk is the API client and the connectable CLI's management commands. See ROADMAP.md for what is built and what is next, and goal.md for the product and technical spec.

End to end

Two harnesses, asking two different questions.

bun run e2e        # does the MCP path work in this checkout
bun run e2e:prod   # does the deployment our users reach work

scripts/e2e.ts starts an upstream, a config file, a gateway and a real MCP client, touches no database, and needs no network access to anything it did not start. That makes it usable as a pre-push check, and it is what CI runs.

scripts/e2e-prod.ts starts nothing. It provisions a connector, an endpoint and a key through the public management API of a deployed origin, connects a real MCP client to /mcp/<slug> over the network, calls the generated tool, and checks the call landed in the audit log, then deletes everything it made. It exists because the failures that reach users are the ones a self-contained test cannot see: an environment variable nobody set, a migration that did not run, a cron route that answers 503 because CRON_SECRET is missing.

It needs two things, and writes to whatever they point at:

CONNECTABLE_URL=https://connectable.sh \
CONNECTABLE_TOKEN=cmt_...              \
bun run e2e:prod

Point it at an organization that exists for it. Everything it creates is named e2e-<run>, and its first act is to sweep e2e- resources older than an hour. That keeps one killed run from exhausting the free plan's connector cap and failing every run after it.

Optionally set CONNECTABLE_E2E_UPSTREAM_TOKEN to a second token scoped to connectors:read. The connector dials the deployment's own management API (no third party, so a red run is never somebody else's outage), and that token is the credential it presents. Unset, the driver token is used for both, which grants nobody anything they did not already have but is a wider credential to have written into a vault.

Set CONNECTABLE_GATEWAY_URL when the gateway is served apart from the dashboard, like the Cloudflare Worker. MCP traffic then goes to $CONNECTABLE_GATEWAY_URL/mcp/<slug>, so a per-org Worker's 308 is part of the run. The gateway has to read the same database as the dashboard, or every endpoint the run creates is a 404 there.

.github/workflows/e2e-prod.yml runs it after a successful "Deploy Cloudflare" run on main, and on workflow_dispatch with an optional url input for pointing it at a preview. It is a separate workflow from ci.yml because it needs credentials no fork may hold and has nothing to say until something has been deployed. Repository secrets: CONNECTABLE_PROD_URL, CONNECTABLE_PROD_TOKEN, and optionally CONNECTABLE_PROD_UPSTREAM_TOKEN.

Routes

/ is the public landing page, /reference this README and /guide USAGE.md. All three are generated from the docs by bun run --cwd packages/ui site and served as static strings; see design-landing-page.md. The signed-in product starts at /dashboard, and everything else (/connectors, /audit-log, …) keeps its own top-level path under the (dashboard) route group. Signing in, signing up and accepting an invitation all land on /dashboard.

/mcp/<slug> is the gateway itself and /api/v1/* the management API. /metrics reports this instance's capacity (see above). On the dashboard Worker it drives no autoscaling, since the isolate count is not yours to set.

MCP sessions and where they can be held

An MCP session owns live connections to every connector on its endpoint, and those are process-local: only the instance that created one can serve a request carrying its id. On Fly, fly-replay routes such a request back to the machine that holds it (affinity.ts). On Cloudflare the front Worker routes by the same session-id suffix, so no replay is needed. On a serverless host (Vercel, a Worker) there is no equivalent (instances are not addressable and there is no sticky routing), and a request that lands elsewhere is not quietly re-initialized: it is answered Bad Request: Server not initialized, and the client's session is over. Measured against production that was roughly one session in twelve.

So the Next app's route runs the transport stateless when VERCEL is set or it's running on Workers: no session id is issued, every request rebuilds what it needs, and there is nothing left to route. Set CONNECTABLE_MCP_SESSIONS=stateful to hold sessions anyway (a single-instance deployment), or =stateless to force it on anywhere else that load-balances without affinity. A gateway on Fly keeps its sessions and should: it can route to them.

What stateless costs, in order of how much it should worry you:

  • Sampling and elicitation are unavailable, and refused outright rather than left to hang (see the protocol table above). This is the one that cannot be engineered around without sessions.
  • Upstreams are re-dialed per request. Narrower than it sounds: an openapi, graphql or web connector keeps its tools in the database, so tools/list performs no network I/O either way. Only a transport: http MCP connector pays a handshake per request.
  • Nothing is pushed between requests. */list_changed and resources/updated have no session to arrive on. Little is lost, since every request re-reads and there is no stale list to correct. Notifications during a call still work: the response is an SSE stream, not a JSON body, precisely so that anything raised while a tool runs has somewhere to go.

Contributing

Please see CONTRIBUTING.md.

License

Proprietary. All rights reserved. See LICENSE.