Connectors
An App calls a person's Gmail or another connected service from its server code, after that person approves it; no token ever reaches the App, the browser, or the agent.
What you can do
Use your own accounts inside an App. Connect a service such as Gmail to your Charming account once. When an App asks to use it, Charming shows its own approval window naming the App, its owner, and what the App may do: read, write, or delete. You approve or refuse each App separately.
Share an App, not your inbox. Everyone who opens a shared Gmail App connects and approves their own Gmail, and sees their own mail. Nobody, the App’s owner included, gets anyone else’s password or token. The owner can also choose to share their own account with the people they share the App with, limited to the access they pick, such as reading and never writing.
Know who can see your data. An App, its owner, and people who can edit it can see the data the App reads from your account, because anyone who can edit the App can change what it does with that data. The approval window says so before you approve.
Take it back at any time. Account settings → Connectors lists every service you can connect, the accounts you connected, and the Apps you approved. Revoke one App, or remove the account to stop every App at once.
Connectors work in every App, with nothing to add to its manifest. Services lists what you can connect.
Technical
A connector is a service, such as Gmail, that a person connects to their Charming account. A Connection is one person’s account at that service. An approval lets one App use one Connection at the access levels it names. Composio holds every OAuth token and runs each call. Charming decides whether a call may run and whose Connection serves it.
Copy this prompt for your agent
Build a Charming app that reads my Gmail. Read
https://charm.ing/docs/capabilities/connectors.md first.
Call only the Gmail tools the app needs from route handlers with
env.connectors.call, naming each service and tool as a string, and
give the app a Connect Gmail button for anyone who hasn't approved
it yet.
Call a connector
Inside a route handler, call env.connectors.call(service, tool, args), where service is a slug from Services and tool is one of that service’s tools. Write both as string literals. Charming reads the calls in the server module when the App is saved and treats the tools named there as the App’s declared tools: they decide which access levels a person approves and which tools a call may run. Nothing goes in the manifest. Saving a module whose call names a service or tool through a variable, or that assigns env.connectors to a variable, passes it to a function, or renames it while destructuring, is refused with invalid_connector_call and the line to fix. Checking it, as in if (!env.connectors), is fine. Browser code cannot call a connector.
export const manifest = {
$schema: 'https://charm.ing/schema/app-manifest/2026-10-16.json',
id: 'inbox-triage',
meta: { name: 'Inbox triage' },
};
export const routes = [
{
op: 'unread',
method: 'GET',
annotations: { readOnlyHint: true, destructiveHint: false },
handler: async (_input, { env }) =>
env.connectors.call('gmail', 'GMAIL_FETCH_EMAILS', {
query: 'is:unread in:inbox',
max_results: 25,
}),
},
{
op: 'archive',
inputSchema: {
type: 'object',
required: ['messageId'],
properties: { messageId: { type: 'string' } },
additionalProperties: false,
},
annotations: { readOnlyHint: false, destructiveHint: false },
handler: async ({ messageId }, { env }) =>
env.connectors.call('gmail', 'GMAIL_ADD_LABEL_TO_EMAIL', {
message_id: messageId,
remove_label_ids: ['INBOX'],
}),
},
];
On the ESM contract ($schema: "https://charm.ing/schema/app-manifest/next.json"), declare each service and its tools under permissions.server.connectors in the manifest instead, such as { gmail: ['GMAIL_FETCH_EMAILS'] }, import connectors from charming:[email protected], and call connectors.call(service, tool, args). Charming refuses any tool the manifest does not declare.
connectors.call resolves with the service’s result data, or throws a ConnectorError (below). App code never names a person or a Connection: Charming picks whose Connection serves the call from who is calling.
Services
A service’s slug is the first argument to connectors.call and charming.connect, and on the ESM contract the key under permissions.server.connectors:
- Gmail (
gmail) - Google Calendar (
googlecalendar) - Google Drive (
googledrive) - Google Docs (
googledocs) - Google Sheets (
googlesheets) - Outlook (
outlook) - Slack (
slack) - Notion (
notion) - Linear (
linear) - GitHub (
github) - Asana (
asana) - Trello (
trello) - Airtable (
airtable) - Dropbox (
dropbox) - HubSpot (
hubspot) - Intercom (
intercom) - Calendly (
calendly) - Stripe (
stripe)
Saving a module that calls a slug not on this list is refused with invalid_connector_call; on the ESM contract, the manifest fails with must be a known connector service.
Tools
Each tool is a Composio tool slug, such as GMAIL_FETCH_EMAILS, run at the toolkit version Charming pins. Find a service’s tools and their arguments on its Composio toolkit page, https://docs.composio.dev/toolkits/<slug>, for example Gmail. Charming’s catalog holds the toolkit’s tools at the pinned version, without deprecated ones, so a tool Composio added since may not be callable yet. Calling a tool the catalog does not carry is refused on save with invalid_connector_call; on the ESM contract, the manifest fails with must be a <service> tool in the connector catalog at that entry’s path, such as /permissions/server/connectors/gmail/2. Arguments are the tool’s own, in snake_case, and at most 64 KiB of JSON.
Gmail tools default user_id to me, the connected account. GMAIL_FETCH_EMAILS returns one email unless you set max_results (up to 500), takes Gmail search syntax in query, and does not sort by date: sort by internalDate yourself. GMAIL_ADD_LABEL_TO_EMAIL archives by removing INBOX and marks read by removing UNREAD; custom labels need their id from GMAIL_LIST_LABELS, not their name.
Access levels
Every tool has one access level:
| Level | What the tool does |
|---|---|
read |
Reads data and changes nothing |
write |
Creates or changes data, such as sending or labelling an email |
delete |
Deletes data, such as GMAIL_DELETE_MESSAGE |
The catalog leaves out tools that would give someone access lasting past a revocation or hand a credential to the App, such as sharing a Drive file, creating a Dropbox file request or upload link, adding a mail rule, filter, or send-as alias, creating a webhook, watch channel, or deploy key, inviting a person or adding them to a team or project, changing who can see a board or repository, weakening branch protection, writing a secret, or minting a token. It also leaves out raw GraphQL and batch request tools, since they can reach any of those. The level comes from Composio’s own tags on the tool: a destructive tool is delete, a read-only one is read, and anything else, untagged included, is write, unless Charming corrects a tag: a tool that sends, publishes, commits, refunds, or moves things is write even when Composio tags it destructive. A person approves an App for the levels its declared tools need, per service, and the approval window describes each level in that service’s words. Levels are independent: approving write does not approve read. When a new version of an App stops declaring every tool at a level, each approval and the owner’s shared access lose that level, and declaring it again later asks again. An App that stops declaring a service loses every approval and setting for it.
Adding a tool at a level the person already approved needs nothing from them; it runs on their next call. Adding a tool at a new level refuses their calls to it with connector_not_granted until they approve that level, and err.missingLevels names it. Their calls to tools at levels they approved keep working.
Errors
A refused or failed call throws an error with name === 'ConnectorError', a kind, and a fixed message. Check err.name, not instanceof. The service’s own error text never reaches the App, so a call with bad arguments reads as connector_unavailable. Arguments over 64 KiB of JSON are refused before any check runs: the call throws a plain Error, not a ConnectorError.
kind |
Means | What the App should do |
|---|---|---|
sign_in_required |
A signed-out visitor called. Signed-out visitors never use a connector. | Sign them in with charming.login() or charming.withUser(...), then call again. |
connector_not_granted |
The caller has not approved this App for this tool’s access level (read, write, or delete), and the owner’s shared account does not cover them. When approving would help, err.missingLevels lists the access levels the App’s declared tools need that they have not approved. |
If err.missingLevels is present and not empty, show a Connect button whose click calls charming.connect(service), and call again when it resolves { granted: true }. If it is absent, approving again changes nothing: the owner’s settings keep this person off the connector, so tell them to ask the owner. |
connector_action_not_declared |
The App does not declare this service or tool. | Call the tool by its literal name in the server module, or on the ESM contract add it to permissions.server.connectors, and save the App. |
connector_no_person |
No person is behind the call: an App token call, a Webhook delivery, a call from another App through charming:app/<manifest-id>@1.0, or a call that names the person only by the App URL an agent handed out, as in an App shown inside Claude or ChatGPT. Also a Routine, or shared access where people cannot use their own account, when the App’s live version or the Routine was last saved by someone who does not manage the App. |
Nothing serves the first four. Run connector work from a person’s call on Charming or through their agent, or from a Routine, instead. For the last, someone who manages the App publishes the version or saves the Routine. |
connector_reconnect_required |
The account’s access expired or was removed at the service. | Ask the person to reconnect: charming.connect(service) offers it, or Account settings → Connectors. |
connector_unavailable |
The service or Composio failed, the service rejected the arguments, or connectors are off on this server. | Show an error and let the person retry. Check the arguments against the tool’s Composio toolkit page. |
connector_call_limit_exceeded |
The App owner’s monthly connector calls ran out. | Tell the person the App is out of connector calls this month. Only the owner’s plan or the new month lifts it. |
connector_rate_limited |
This person made more than 60 connector calls to this App in a minute. | Wait a minute, then retry. Fetch in pages instead of one call per message. |
connector_cross_site |
A link, form, or script on another site sent the signed-in person to the App, so the call may only read. A write or delete call refuses; a read call runs. |
Make changes from the App’s own page, through charming.api(...), never from a URL another site can open. |
try {
return await connectors.call('gmail', 'GMAIL_FETCH_EMAILS', {
query: 'is:unread',
max_results: 25,
});
} catch (err) {
if (err.name === 'ConnectorError' && err.kind === 'connector_not_granted') {
return { needsConnect: true, missingLevels: err.missingLevels };
}
throw err;
}
A person who may only view the App, without app:run, may only read through a connector too, whoever’s Connection would serve them: a write or delete call throws the same forbidden_write error a storage write does, before Charming looks for a Connection, and the App API answers 403.
A ConnectorError the handler does not catch becomes the operation’s own refusal, with the same kind. The App API answers 401 for sign_in_required; 403 for connector_not_granted (with error.missingLevels when approving would help), connector_action_not_declared, connector_no_person, and connector_cross_site; 409 for connector_reconnect_required; 429 for connector_rate_limited and connector_call_limit_exceeded; and 503 for connector_unavailable. connector_call_limit_exceeded is a plan refusal with error.plan, error.limit: "connector_calls_per_month", error.max, and error.upgradeUrl only when the caller is the App’s owner or an owner or admin of the team that owns it, or the call is a Routine tick, whose failures the owner reads. Anyone else, including a signed-in visitor of a public App, gets the same kind with a generic message and none of those fields. MCP query_app and mutate_app return the same kind as error_kind, with missingLevels in error_details. In the browser, charming.api(...) rejects with a CharmingOperationError carrying that kind. A call from another App through charming:app/... gets no such refusal: an uncaught ConnectorError in the called App fails that call as operation_failed, so catch it in the called handler and return the answer you want the caller to see.
Ask for approval from the browser
charming.connect(service: string): Promise<{ granted: boolean }>
charming.connect.available: boolean
charming.connect.unavailableReason?: 'embedded-host' | 'custom-domain' | 'no-connectors'
charming.connect(service), such as charming.connect('gmail'), opens Charming’s approval window on Charming’s own origin. It lists what the App may do at each access level its declared tools need, says the App, its owner, and people who can edit it can see the data it reads, and lets the person connect the service if they have not, then approve. It resolves { granted: true } once they approve, and { granted: false } if they close the window or leave it open for 10 minutes without answering, when Charming closes it. Call it straight from a click: a blocked window rejects with a CharmingConnectError carrying reason: 'popup-blocked'.
Check charming.connect.available before offering the button. It is false inside a chat host (embedded-host), on a custom domain (custom-domain), and in an App that declares no connectors (no-connectors). There, charming.connect() rejects with a CharmingConnectError whose reason is that unavailableReason; send the person to the App’s Charming URL instead.
Whose account a call uses
| Caller | Whose Connection |
|---|---|
| A signed-in person who approved this App | Their own |
| A signed-in person who has not, when the owner shares their account with that person’s role | The owner’s shared account, limited to the access levels the owner shared, while someone who manages the App published its live version. Otherwise connector_not_granted with missingLevels, so they can approve their own, or connector_no_person when people cannot use their own |
| A signed-out visitor | None: sign_in_required |
| A Routine | The App’s connection holder, while someone who manages the App published its live version and last saved the Routine; otherwise connector_no_person |
An agent through query_app or mutate_app |
The person the agent is signed in as, by the same rules as that person in a browser. An agent never reaches anyone else’s Connection |
An App token, a Webhook delivery, or another App calling through charming:app/... |
None: connector_no_person |
| The App’s own page shown inside an agent such as Claude or ChatGPT, which names the person only by the App URL the agent received | None: connector_no_person. That URL is a long-lived link anyone it reaches can open, so it never acts as the person. Connectors need a signed-in Charming session, in a browser on Charming or the App’s custom domain, or the agent’s own sign-in through query_app and mutate_app |
A person’s own approval always wins over the owner’s shared account. The connection holder is the one person whose Connection serves Routines and owner sharing: the first person who can manage the App and approves it for that service, normally the owner, or a later manager who turns sharing on. App settings names them. While they can manage the App, the holder’s own calls use their own approval whatever the settings below say. Transferring or claiming the App removes every person’s approval and clears the holder. Each person approves again under the new owner, whom the approval names. Until someone who manages the App approves it again, sharing stays off and Routines refuse with connector_not_granted. A holder who loses manage access stays the holder, but sharing and Routines stop until they regain it or someone else who manages the App turns sharing on.
Routines and shared access use the holder’s account only while the App’s live version and the Routine were last saved by someone who manages the App. Charming checks this on every call, so a collaborator who can edit code or Routines but not manage the App cannot reach the holder’s account: once they publish a version or save a Routine, those calls refuse until a manager publishes or saves again. A Routine refuses with connector_no_person. A person the account was shared with gets connector_not_granted with missingLevels, as if nothing were shared, so the App can ask them to approve their own; when people cannot use their own, they get connector_no_person. A person’s own approval works whoever published the code.
What the owner controls
The owner sets each declared service in App settings → Connectors at /<owner-handle>/~/apps/<app-name>/settings/connectors. The switches are named for the service. For Gmail they are:
- People can use their own Gmail. On by default. Off means only the shared account serves other people’s calls, and Charming refuses their approval of the App, since it would change nothing.
- Share your Gmail. Off by default. On shares the owner’s approved account with the roles they pick (App members, Other people signed in to Charming), at the access levels they pick from those the App’s declared tools need. Turning it on shares
readalone when the App declares it, and nothing otherwise;writeanddeleteare shared only when picked. A dashboard can sharereadand neverwrite.
Only the holder can share their account more widely. Another manager can remove roles or access levels, turn sharing off, or turn it on with their own account, which makes them the holder; adding a role or level to someone else’s account is refused with connector_holder_only. A new holder starts from read shared with App members, whatever the previous holder chose, unless they pick other roles and levels in the same change.
Copying an App or a Template copies no approvals and turns sharing off.
Limits, access, and deletion
- Monthly allowance. A call counts against the App owner’s plan once Charming sends it to the service, whoever’s account served it, even if the service then fails. A call Charming refuses first never counts: one not approved or not declared, over the per-person rate, needing a reconnect, or over the argument limit. Free gets 500 calls a month, Pro 10,000, Business 10,000 per seat. Months are calendar months in UTC. See Plans.
- Per-person rate. 60 connector calls a minute per person per App. Routine calls share one bucket.
- Arguments. At most 64 KiB of JSON per call. A larger call throws a plain
Errorin the handler. - Claimed Apps only. An unclaimed App has no owner to count calls against, so every call refuses.
- What the App does with the data is up to the App. Charming does not stop App code from storing what it reads. Never write data from a person’s account into the App’s shared
kvstorage, where every other person using the App can read it. Fetch on open and return the result to the caller instead. - Removing a Connection. Account settings → Connectors, at
/<handle>/~/settings/connectors, lists every catalog service with a Connect button or the connected account. An account connected to a service the catalog no longer lists stays on the page, with Remove only. Remove takes the account out of Charming and Composio and revokes it at the service where Composio supports that; otherwise the page tells the person to remove Charming’s access in the service’s own account settings, and links Google’s connections page for Google services. Revoking one App’s approval leaves the Connection for the person’s other Apps. When the removed Connection or revoked approval belongs to an App’s holder, nobody holds that App’s service and sharing turns off until a manager approves again, so a later approval never resumes sharing on its own. Deleting a Charming account deletes its Connections. - Reconnecting a different account. Reconnecting the same account keeps every approval. Charming counts it as the same only when the service returns the same stable identity: the email or login for Google (Gmail, Calendar, or Drive), GitHub, Outlook, Airtable, Asana, Calendly, Dropbox, and Trello, and the service’s own id for Slack (user and workspace), Notion, Linear, HubSpot (portal), and Stripe (account), where one login reaches several workspaces or accounts. The name shown for the account never counts. Google Docs, Google Sheets, and Intercom have no tool that names the account, so a reconnect there always counts as a different account. Reconnecting a different account, or one the service does not identify, removes every approval made through that Connection: each App asks again, and on Apps where the person’s account was the shared one, nobody holds it and sharing turns off until a manager approves again.
- Logging. Charming logs each call’s App, service, tool, caller kind, and outcome, never its arguments or results.
Related
- Secrets: call services that take a static API key
- Privacy and sharing
- Limits
- Browser runtime API
- Docs home
- llms-full.txt