Skip to main content

Data Connectors

A data connector gives the agent one data source — a calendar, a mailbox, a document store — with a capability class per operation. Reading your calendar and writing to it are two different tools, and the gate answers about each one separately.

That difference is the whole reason connectors exist as their own kind. Read capability-gates.md first: everything below keys on the classes that page defines.

Why not an MCP server

An MCP server can reach the same API. It cannot tell the gate what its tools do.

An MCP tool declares no capability class, so capability_class_of() resolves every one of them to mutate.remote — the fail-closed value. A listing and a send land in one class. The consequence is not theoretical: the standing grant that lets an automation read your mail also lets it send mail, because the gate cannot tell the two apart.

A connector declares a class per operation, and a manifest that classifies a POST as a read is refused when it loads. So:

OperationClassInteractiveUnattended
google_calendar_list_eventsreadrunsruns
google_calendar_list_calendarsreadrunsruns
google_calendar_get_eventreadrunsruns
google_calendar_create_eventmutate.remotea person approves itneeds a standing grant that names it

Connectors do not replace MCP. Use MCP for a tool somebody else maintains and you invoke occasionally. Use a connector when the agent works with a data source often enough that "may it read this?" and "may it write this?" need different answers.

What ships

ConnectorOperationsClasses
google-calendarlist_events, list_calendars, get_event, create_eventread, mutate.remote

nanoinfra connectors list prints this for the deployment in front of you, including which operations are actually enabled.

Setting one up

Three steps: an OAuth client, one consent, one config block. Nothing is active until the config names it.

1. Create an OAuth client

In Google Cloud Console → Credentials, enable the Google Calendar API for the project, then create an OAuth client ID of type Desktop app. You get a client id and a client secret; both are inputs to the next step.

A desktop client accepts a loopback redirect on any port, so there is no redirect URI to register--port moves the flow freely. Choose Web application instead and you must register http://127.0.0.1:8766/ exactly, and change both together.

On a Workspace domain, set the consent screen audience to Internal. The Calendar scopes are sensitive ones, and an internal app serves accounts in your own domain and skips Google's verification queue.

One client per deployment. A shared client id would mean a shared quota, a shared verification status, and one revocation that affects everybody.

From the WebUI: Apps → Connectors → Connect. Enter the client id and the client secret, consent as the account the connector should act as, and the browser comes back to this deployment's own origin. The credential and the activation are written for you, and nothing is copied by hand. Register https://<your-origin>/auth/connector/callback as the authorised redirect URI on the OAuth client — Google validates it byte for byte.

Or from a terminal, which is the path for a laptop with a browser on the same machine:

nanoinfra connectors authorize google-calendar \
--client-id 1234-abc.apps.googleusercontent.com \
--account you@example.com

The command prompts for the client secret so it stays out of your shell history, opens the consent screen, and stores two secrets — the refresh token and the client secret. It prints their ids and neither value.

The connector acts as whoever completes this flow. Consent as the account whose calendar the agent should work with.

Nothing about this step is reachable from a chat turn, and that is deliberate: an authorisation the agent could start would be an authorisation nobody performed. The WebUI route is an operator surface behind the same authentication that already edits the gates policy, and no tool reaches it.

The consent that is in flight lives in memory with a ten-minute life: the PKCE verifier, the state, the redirect and the scopes asked for. The state is what authorises the callback, because Google redirects a browser carrying a cookie and no bearer — it is single-use and expiring, and one that matches no pending consent answers 404 and records nothing. A consent that fails writes nothing at all, so there is no half-configured credential to clean up.

3. Activate it in config

The Connect flow writes this for you. The CLI prints it to add by hand:

{
"connectors": {
"credentials": {
"google_calendar_credential": {
"clientId": "1234-abc.apps.googleusercontent.com",
"secretRef": "3f2a…",
"clientSecretRef": "9c41…",
"scopes": [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events"
]
}
},
"connectors": {
"google-calendar": {
"credential": "google_calendar_credential",
"settings": { "calendarId": "primary" }
}
},
"active": ["google-calendar"]
}
}

Then reload, or restart. Reload connectors on the Apps page re-registers the tools against what config now says — a config edit alone does not, and neither does docker compose up -d when only the file inside the volume changed: it answers Running and changes nothing. The row raises a banner when config activates a tool the running agent does not hold.

The boot log names what activated:

connectors: google-calendar (3 ops, read/mutate.remote)

A connector that did not activate says why on the same line, and contributes no tools. A half-working connector would give the model a tool that always fails.

For every key, see configuration.md#data-connectors.

What each key protects

credential is the grant. A connector resolves the credential that config names for it, and nothing else. There is no second allow-list to keep in agreement with it, which is why four Google connectors can share one OAuth flow and one refresh token without any package naming its own peers.

Scopes are what stop lateral use. Each connector declares the scopes it needs per class, and the token is minted for the intersection of that and what the credential was granted. A read receives a token that cannot write. A Docs connector sharing a Google credential that holds gmail.send receives a token that cannot send mail. Widening means editing config.

maxClass caps what a package may offer. A connector declares its own classes, and a package that arrived from a marketplace declaring "this call is a read" is self-certification. "maxClass": "read" leaves it no writes however its manifest is written.

A manifest field default applies without config repeating it. Calendar declares calendarId: "primary", so a deployment that says nothing about it works on the primary calendar. An operator's settings win over it, because config is the authority over a package.

enabledOperations caps what the model sees. Same choice mcpServers.<name>.enabledTools offers, for the same reason: a deployment that only reads a calendar should put two tools in the context window, not nine.

A mismatch between any of these is refused when the connector activates, with both halves named — a credential missing the write scope, an operation the package never declared, a ceiling that leaves nothing to call. Not at 03:00 in a run record.

How a call runs

The connector call goes over the same wire a remote command does, and it is performed in the executor process. Three of the four things it needs live there and cannot leave: the refresh token, the approval socket, and the audit log.

  1. The agent submits a connector name, an operation name, and the call arguments. It sends no URL, no method and no token.
  2. The executor reads the method, the path, the class and the scopes from the installed manifest, and validates the arguments against the operation's own schema. An undeclared argument is refused.
  3. The gate decides. A read runs. A mutate.remote asks a person, or matches a standing grant, or refuses.
  4. The executor mints an access token for that class's scopes, makes the call, and reduces the response to the fields the operation declared.
  5. The audit log records the connector, the operation, the class, the rendered call and the credential ref.

So a compromised agent can ask for a calendar write and cannot make one. See architecture.md#process-split.

A write, interactively

A write defaults to dry_run=true. The executor renders exactly what it would send and sends nothing:

POST https://www.googleapis.com/calendar/v3/calendars/primary/events
{"end": {"date": "2026-09-02"}, "start": {"date": "2026-09-02"}, "summary": "Design review"}

Ask for it, and the action suspends. The approval you answer in the WebUI carries those same bytes, values included, and the binding digest covers them — so what you read is what runs. See capability-gates.md#the-approval-path.

A write, unattended

An automation that has to write needs a standing grant, and a grant can now name a connector:

{
"id": "calendar-hold",
"contexts": ["unattended"],
"connectors": ["google-calendar"],
"operations": ["create_event"]
}

Set gates.unattended.mutate.remote.host to grant for it to apply.

A grant names one kind of action: hosts and commands, or connectors and operations. A grant that named both would have to mean either "and" or "or", and both readings are wrong, so the config refuses it. A grant written for create_event covers no shell command, and a grant written for a host covers no connector call.

In the WebUI

Settings → Apps → Connectors. The chip filters the connector rows; an active connector also appears under Ready, because Ready is a state filter across kinds — the same bar an MCP server clears by being installed and configured.

The row answers the posture, which is the job Settings → Identity does for a person: who it acts as, and what it may do.

Google Calendar Connector acts as you@example.com · refreshed 12m ago
read allow · mutate.remote approve (unattended: needs a grant)
not granted: calendar.events — operations that need them are unavailable

Expand it for the operations with their class and both gate answers, the scopes with a mark per class, the settings the manifest declares, and the command to re-authorise.

Connect starts the consent, and Test is the action worth having. It performs one declared read through the executor — the whole path in one call: the client, the consent, the refresh, the scope subset, the projection, and the gate answering allow — and reports what came back plus the account it came from. A connector that has never been tested and one whose token was revoked look identical without it, and the difference matters at 03:00.

Three things the row deliberately does not have:

  • No enable toggle. Activation is connectors.active in config, applied when the agent starts, because enabling a connector is what gives a package a token and a capability class. A toggle here would be a second authority contradicting the first.
  • A Connect button, and no automatic authorisation. Consent is a person at a browser: the row starts the flow and the browser does the rest. Nothing the agent can call starts one.
  • No data browser. The surface for data is the conversation. The only data the row shows is the test result, and it is there to prove a credential works rather than to be read.

Pinning an object with a mention

Type @calendar: in the composer and the picker lists the calendars this account can see, each with its id, its access role and its timezone. Choosing one puts the id in the message and sends it as a reference beside the text.

A mention is not for attaching content. It pins identity, so a task does not begin with a search — and the value is in an automation, where a fuzzy match on a name is re-done on every run, so a rename or a second calendar silently changes what the 03:00 run touches.

The agent receives a reference rather than data, and the reference says which call takes it:

{"kind": "calendar", "id": "team@group.calendar.google.com", "name": "Team calendar",
"connector": "google-calendar", "detail": "owner · America/Mexico_City",
"use": "pass calendarId=team@group.calendar.google.com to google_calendar_list_events"}

Two properties worth knowing:

  • The list is refreshed by the picker and cached; a mention resolves against the cache. A mention is resolved on every send, and a live listing there would put a network call on the path of every message and fail a send when an API is slow.
  • An id the cache does not know is refused, exactly as a deleted server is: a chat turn drops the mention, and an automation stops rather than improvising around the gap.

A connector declares which of its objects are mentionable, and which operation lists them. That operation has to be a read — a picker that could write would be a write nobody asked for.

Loading a connector only when you ask for it

An active connector's operation schemas are in every prompt, on every turn, whether or not the turn is about a calendar. Set it to load on mention instead:

  1. Open Apps in the sidebar and choose the Connectors tab.
  2. Click Load on mention on the connector's row.
  3. Restart when prompted. The registry is built at start.

In config, the same state is one field:

{
"connectors": {
"connectors": {
"google-calendar": { "credential": "google_calendar_credential", "attach": "mention" }
}
}
}

The connector stays active. It keeps its credential, its grants and its operation list, and the prompt carries one line naming it, its operation count and how to reach it — about fifty tokens instead of every schema.

Two things count as naming it:

  • @google-calendar loads its operations for that turn.
  • @calendar:<id> pins one calendar and loads the connector. Pinning a specific calendar is naming the calendar connector, so you never have to write both.

The advertised line is why this is not the same as removing the connector. A model that cannot see that a capability exists cannot say "I can do that if you attach google-calendar" — it fails, or it uses something worse without telling you.

An unattended turn types no @, so a cron job or a trigger declares what it needs:

{ "connectors": ["google-calendar"] }

Every other run then stops paying for those schemas.

Letting the model load it by searching

mention waits for a person to name the connector. attach: "search" is the model-driven counterpart — the schemas are withheld the same way, but the model loads the connector itself by calling the built-in tool_search tool with a topic (calendar), no @ required. In the WebUI the connector's button cycles Load every turn → Load on mention → Load on search; in config it is the same one field:

{
"connectors": {
"connectors": {
"google-calendar": { "credential": "google_calendar_credential", "attach": "search" }
}
}
}

mention pays one advertised line per connector; search replaces the enumeration with a single shared pointer, so it is the mode that scales when several connectors are deferred. Both respect an agent's connectors ceiling. The same tool_search tool spans built-in tool groups, MCP servers and connectors — see Built-in tool groups → Letting the model search for a group.

What comes back

An operation declares the fields it returns, and the response is reduced to them. A calendar event carries conference data, reminders, response statuses and a dozen link fields; handing that to a model spends the context window and teaches it nothing. Naming the fields also caps what a call that is allowed can carry out.

list_events returns id, summary, start, end, status and each attendee's email, plus nextPageToken when the listing hit its cap.

list_calendars returns id, summary, primary, accessRole and timeZone. It is what the mention picker calls, and the agent can call it too when somebody names a calendar it has not been given the id for.

Failure modes worth knowing

What happenedWhat you see
The refresh token was revokedevery class refuses, naming the connector, and says it needs re-authorising
A scope was never grantedthe connector does not activate, and the message names the missing scope
An unattended turn wants a writerefused, naming the grant that would permit it
Rate limited (429)a failure of the action, with Retry-After when the API sent one
The executor is not runninga deployment fault, in different words from a refusal

To re-authorise, run connectors authorize again and update secretRef to the new secret. Google returns a fresh refresh token because the flow asks for prompt=consent.

Writing a connector

A connector is a Python package under nanoinfra/connectors/<name>/ with a manifest.py that declares one ConnectorPlugin. It is the channel package contract pointed at data instead of at messages, plus operations.

PLUGIN = ConnectorPlugin(
name="google-calendar",
display_name="Google Calendar",
base_url="https://www.googleapis.com",
credential=ConnectorCredentialSpec(
kind="oauth2",
scopes={
"read": ("https://www.googleapis.com/auth/calendar.readonly",),
"mutate.remote": ("https://www.googleapis.com/auth/calendar.events",),
},
token_url="https://oauth2.googleapis.com/token",
),
setup=SETUP,
skill="SKILL.md",
operations=(
operation(
"list_events",
"read",
"GET",
"/calendar/v3/calendars/{calendarId}/events",
returns=("id", "summary", "start", "end", "status", "attendees.email"),
collection="items",
),
operation("create_event", "mutate.remote", "POST", "/calendar/v3/calendars/{calendarId}/events"),
),
)

Four rules the loader enforces:

  • The package directory name and name must agree, because the directory name is what config refers to.
  • base_url must be https. A token travels on it.
  • A path placeholder is filled from a named argument and URL-quoted whole, so a value carrying / stays a value.
  • A read class on a writing method is refused. That check is what makes the declaration worth trusting.

There is no runtime class and no SDK. base_url plus a method and a path is the whole request surface, which is what keeps a declared class checkable: a reviewer, and the loader, can see that a read is a GET. A discovery-based client would hide both.

Declare mentions=(ConnectorMentionSpec(kind="calendar", operation="list_calendars", argument="calendarId"),) for objects a person should be able to pin. argument is what makes a pinned id useful rather than decorative: it names the operation argument the id fills, so the runtime context can say pass calendarId=<id> instead of leaving the model to guess.

Ship a SKILL.md beside the manifest. It does what a tool schema cannot: name which operation is the consequential one, and state the idioms — that an all-day event carries date and not dateTime, that a declined invitation is still an event, that a listing which hit its cap is a partial answer.

Hello world: the smallest connector that runs

examples/connectors/hello-world/ in the repository is one read against a public weather API, with no credential at all. Copy it into a workspace and it works:

mkdir -p ~/.nanoinfra/workspaces/default/connectors
cp -r examples/connectors/hello-world ~/.nanoinfra/workspaces/default/connectors/
{
"tools": { "connectors": true },
"connectors": {
"active": ["hello-world"],
"connectors": {
"hello-world": { "settings": { "latitude": "19.4326", "longitude": "-99.1332" } }
}
}
}

nanoinfra connectors list shows it, and the agent gets one tool, connector_hello_world_current_weather, carrying the class read. A policy that allows reads never asks about it, because the declaration says it is one and the loader checked that a read is a GET.

No credential key is needed, because the package declares credential.kind: "none". Nothing is minted and no Authorization header is sent — which is why this installs with no setup, and the reason it is the example: the shape of a connector is worth seeing without an OAuth round trip in the way.

The answer comes back projected to the six fields the manifest declares:

{
"latitude": 19.437609,
"longitude": -99.10715,
"timezone": "GMT",
"current": { "time": "2026-08-31T21:45", "temperature_2m": 20.3, "wind_speed_10m": 6.0 }
}

The API returns far more than that. Naming the fields is what keeps a connector's answer useful rather than a wall of hourly arrays, and it caps what a call that is allowed can carry out.

Turning it into a real one

Five changes, and the last is the one to get right:

  1. name, matching the directory name — that is what config refers to.
  2. baseUrl, which must be https. A token travels on it.
  3. operations, one per call, each with a class. A read on a writing method is refused, and that refusal is what makes the declaration worth trusting.
  4. returns, the fields to keep.
  5. credential: the kind, the scopes each class needs, and allowedHosts. A manifest declares where a token goes, so a package naming your scopes and a baseUrl nobody reviewed would otherwise receive a live token. See below.

Installing one from the catalog

A connector can also arrive from the skills-server catalog, which now publishes three kinds of package: a skill, an Agent Plugin, and a connector. The kind is decided by the file at the root of the archive — connector.json for a connector — and the listing says which one you are installing, because approving a connector is approving requests your deployment will make with a live credential.

A marketplace package is declarative. It is connector.json and nothing importable:

<workspace>/connectors/acme-crm/
├── connector.json # the only entry point
└── SKILL.md # optional, loaded when the connector is active
{
"$schema": "https://nanoinfra.org/schemas/connector/1.0.0/connector.schema.json",
"name": "acme-crm",
"displayName": "Acme CRM",
"baseUrl": "https://api.acme.example",
"credential": {
"kind": "oauth2",
"tokenUrl": "https://api.acme.example/oauth/token",
"allowedHosts": ["api.acme.example"],
"scopes": { "read": ["crm.read"], "mutate.remote": ["crm.write"] }
},
"setup": { "fields": [{ "name": "accountId", "kind": "string", "required": true }] },
"operations": [
{ "name": "list_contacts", "class": "read", "method": "GET", "path": "/v1/contacts",
"collection": "items", "returns": ["id", "name"] },
{ "name": "create_contact", "class": "mutate.remote", "method": "POST", "path": "/v1/contacts" }
],
"dependencies": []
}

JSON rather than Python for one reason: importing a manifest.py this deployment did not write is running it. So the format runs no code at all, and an archive holding anything importable is refused — on every load, not only at install, because a directory is a directory and nothing stops it changing between two gateway starts.

Both formats produce the same ConnectorPlugin, so from the registry down a marketplace connector and a bundled one are the same object: the gate, the maxClass ceiling, the per-class token and the audit record are unchanged.

What is refused before a single file is written

  • An unknown key, at any level. A refusal rather than an ignored field, so a package cannot carry an instruction a later version would start honouring.
  • A non-empty dependencies. A declared dependency means the package expects a runtime that does not exist here. Refused rather than dropped: a package whose dependencies were ignored would fail at its first call instead of here, where somebody is reading.
  • A read class on a writing method, and any class that is not one of the five.
  • A baseUrl that is not https, whose host is outside allowedHosts, or an operation path that is absolute — an absolute path would route a token past the host check entirely.

allowedHosts, and why it is the one that matters

This is the check with teeth, and it is not the one the design originally reached for.

A package declares its own baseUrl. The executor mints an access token for the scopes the operation's class asked for, and sends it there. So a package declaring Google scopes and baseUrl: https://evil.example receives a live Google token — and a confined host process forwards it just as obediently as the executor would, because the token is in the request the manifest asked for rather than in the process's memory. Landlock does not stop an outbound HTTPS call; that is the one thing a connector host exists to do.

What refuses it is a credential bound to the hosts it may address:

{
"connectors": {
"credentials": {
"acme_credential": {
"clientId": "...",
"secretRef": "acme_refresh_token",
"allowedHosts": ["api.acme.example"]
}
}
}
}

Exact matches, no wildcards: api.acme.example does not authorise evil-api.acme.example. Empty means the manifest decides, which is what a first-party package reviewed in this repository gets. A mismatch is refused at activation with both hosts named, the same way a maxClass mismatch names both halves — a message that says only "refused" makes you guess which side to change, and the answer is almost always the config.

Where a marketplace connector's call runs

In a fourth confined process, nanoinfra-connector (uid 1004), beside the executor, the fetcher and the MCP host. Not because the format runs somebody else's code — it does not — but for the two reasons that survive that:

  • The executor holds the credential store, the plaintext keys and the audit log. Performing a stranger's HTTPS request from that process is a larger blast radius than the request needs, and moving it costs one socket.
  • It is the process a runtime hook would need. Signing, cursor pagination and non-standard auth are the reasons a package will eventually want to ship code, and building the boundary now makes that a new request kind rather than a migration of the credential store.

Its group holds the executor and not the agent — unlike the MCP host's, whose group does include the agent — because a connector call starts in the executor after the gate answered. The Landlock policy grants read on connectors/ and nothing else in the workspace, and outbound 443 only.

What crosses that socket: the package directory name, the operation, the validated arguments, the rendered URL, one short-lived access token, and a deadline. What does not: the refresh token, the credential id, the secrets directory, the config, the session key, and the gate's decision. The host re-reads the package and refuses a URL that is not its own, because a frame naming a real package with a URL from somewhere else would be a request that process makes with a live token.

Installing writes a package. Enabling is still a config actiontools.connectors plus connectors.<name> — the same rule tools.agentPlugins states on the Apps page. A browser action that also granted a credential would be an authorisation nobody performed.

Installing one from the catalog, in the WebUI

Apps → Connectors has a search box. What comes back is not a name and an Install button: each row shows what installing it would allow, read from the package itself —

  • every operation, with its capability class,
  • the hosts a token could reach,
  • the scopes that token would carry.

That order is deliberate. credential.allowedHosts is the field that stops a hostile manifest getting a live credential, and a row that hid it behind a click would put the only check that matters somewhere nobody looks. A package the catalog cannot read says so, rather than rendering as a connector that asked for nothing.

Installing is not activating. The package lands in connector-packages/ and does nothing: it has no credential and is not in connectors.active. The row says what is still missing rather than reading as finished.

A skill, an Agent Plugin and a connector are three kinds in one catalog, and each goes where its own subsystem looks — skills/, plugins/, connector-packages/. The kind is read from the catalog rather than taken from the caller, because where a package lands decides what it can do.

What a connector does not change

A connector isolates a data source, not authority. Its writes go through the same gate, the same approver set and the same audit log as a remote command. Reaching a calendar is not the same as being able to act on a host, and a connector does not blur that.

Two people sharing a deployment share its connectors: the connector acts as whoever authorised it, and the record says so. Per-person credentials do not exist — the credential store belongs to the deployment.