Connected accounts
Let users sign in to your service with OAuth instead of pasting API keys — the PKCE flow through Chataway's relay, token refresh, and what to register with your OAuth provider.
Store apps don't ask for API keys. Instead, users connect their account on your service with a normal sign-in screen, and Chataway attaches the resulting token to your tool calls. You authenticate the request exactly as you would any other OAuth client's — and you know which of your users is calling, without Chataway telling you anything about them.
#Declare an account
{
"accounts": [
{
"id": "acme",
"label": "Acme",
"authorizeUrl": "https://auth.example.com/oauth/authorize",
"tokenUrl": "https://auth.example.com/oauth/token",
"revokeUrl": "https://auth.example.com/oauth/revoke",
"clientId": "chataway-3f9c1a",
"scopes": ["profile", "assets:write"],
"required": true
}
],
"contributes": {
"tools": [
{ "id": "upload_images", "name": "Upload images", "description": "…", "account": "acme" }
]
}
}| Field | |
|---|---|
id | Your name for this account: lowercase letters, digits, _. Tools reference it with account. |
label | Shown on the Connect {label} button and in settings. |
authorizeUrl, tokenUrl | Your OAuth 2 endpoints. https for the store; in developer mode, http://localhost is accepted with a warning so you can test against a local OAuth server. |
revokeUrl | Optional. Called with the token when the user disconnects (RFC 7009). |
clientId | Your public client id. Never a secret — the manifest is public. |
scopes | Requested scopes. Ask for the least you need; adding scopes later needs review and fresh consent. |
required | true → Chataway offers Connect right after the app is enabled. Otherwise it's offered the first time a tool needs it. |
params | Extra authorize parameters, e.g. { "audience": "https://api.example.com" }. May not set client_secret, redirect_uri, code_challenge or state. |
An app can declare several accounts (say, Acme and Acme Analytics), each with its own tools.
Accounts are per app, not per project: one Acme sign-in serves every project where the user enabled your app.
#The flow
Chataway uses the OAuth 2.1 authorization code flow with PKCE, as a public client: there is no client secret anywhere, because nothing shipped to a user's computer can keep one.
- Chataway creates a random
stateand a PKCEcode_verifier, and opens yourauthorizeUrlwithresponse_type=code, yourclient_id,scope,state,code_challenge(S256) andredirect_uri=https://chataway.co/apps/oauth/callback. - The user signs in to your service and approves.
- Your server redirects to the callback. The relay keeps
{ state → code }for 5 minutes and shows "You can close this tab". This works even when the user started the flow on their phone. - The Mac that started the flow picks up the code (once — it's deleted when read).
- The Mac exchanges
code+code_verifierat yourtokenUrldirectly — the token request never passes through chataway.co.
The user has 10 minutes to finish signing in. The token request is a standard form post (application/x-www-form-urlencoded, Accept: application/json) with grant_type=authorization_code, code, code_verifier, client_id and the same redirect_uri; your response needs access_token, and should include refresh_token and expires_in.
The relay only ever sees an authorization code, which is useless without the verifier that never leaves the Mac.
Tokens are stored in Chataway's encrypted vault on the Mac, under your app and account id. They're never shown to the agent.
#Your MCP server already does OAuth? Use auth: "mcp"
If your MCP endpoint implements the MCP authorization spec — a 401 with WWW-Authenticate: Bearer resource_metadata="…", Protected Resource Metadata (RFC 9728), Authorization Server Metadata (RFC 8414) and Dynamic Client Registration (RFC 7591) — declare the account without endpoints:
{
"remote": { "mcpUrl": "https://mcp.example.com/mcp", "domains": ["mcp.example.com"] },
"accounts": [{ "id": "acme", "label": "Acme", "auth": "mcp", "scopes": [], "required": true }]
}Chataway then discovers your authorization server from remote.mcpUrl, registers itself as a public client (token_endpoint_auth_method: "none", redirect https://chataway.co/apps/oauth/callback), and runs the same PKCE flow through the relay with the resource parameter (RFC 8707) on the authorize, token and refresh requests. The bearer is sent on every request to your endpoint (initialize and tools/list too). A 401 gets one refresh and a retry; if that fails the user sees Connect again.
scopes: []→ Chataway asks for thescopefrom yourWWW-Authenticateheader, else your PRM'sscopes_supported.- No registration endpoint? Add
"clientId"— a public client you registered for the redirect above. Without either, the account can't be connected. authorizeUrl/tokenUrlare ignored withauth: "mcp".
This is also how users add hosted MCP servers by URL as connectors in the app — the same flow, with a generated manifest.
#What to register with your OAuth provider
Create a client for Chataway with:
| Setting | Value |
|---|---|
| Client type | Public (no secret) — sometimes called "native" or "SPA" |
| Redirect URI | https://chataway.co/apps/oauth/callback (exact match) |
| Grant types | authorization_code, refresh_token |
| PKCE | Required, S256 |
| Token endpoint auth | none |
| CORS on the token endpoint | Not needed — the exchange is made by the desktop app, not a browser |
Issue refresh tokens — otherwise users have to reconnect every time the access token expires. Rotating refresh tokens are fine; Chataway stores the new one on every refresh.
#Using the token on your server
For tools that declare account, every call carries:
POST /mcp HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
X-Chataway-Project: Qm9xY2Fyc3RhcnQ1bE2pX7kN0vR4sT8w
X-Chataway-Chat: 7Hk2LmPq0aZxY3vB9sR1tU4wQ6eD8fGhValidate the token the way your API always does (introspection, JWT verification) and use it to find your user. With the helpers:
import { AccountRequiredError } from '@chataway/apps/server';
app.tool('upload_images', { input: { images: fileParam({ multiple: true }) } }, async ({ images }, ctx) => {
const token = ctx.requireAccount(); // throws AccountRequiredError when missing
const user = await acme.verify(token); // your own auth
if (!user) throw new AccountRequiredError('Your Acme session expired.');
// …
});Chataway only calls an account tool with a token: when the account isn't connected (or its refresh was refused), the user gets the Connect card first. If your API rejects a token Chataway still holds (say, the user revoked access on your site), return an error that says so — throwing AccountRequiredError does that — and tell the agent to ask the user to reconnect from the app's settings.
#Refresh and expiry
- Chataway refreshes the access token about a minute before it expires (using
expires_infrom your token response), withgrant_type=refresh_token. If you don't return a new refresh token, the old one is kept. - If refresh fails (revoked, expired refresh token) — or there's no refresh token — the account is dropped and the next call raises a Connect card.
expires_inmissing → the token is treated as long-lived until your API rejects it.
#Disconnect
The user can disconnect in the app's settings. Chataway deletes the tokens and, if you declared revokeUrl, revokes the refresh token (or access token) there. Your server should treat a revoked token as it would for any client.
#In your panel
The panel can check account state and use the token to call your API directly:
const accounts = await app.accounts.list();
// [{ id: 'acme', label: 'Acme', connected: true, expiresAt: '2026-10-01T09:30:00Z' }]
if (!accounts[0].connected) await app.accounts.connect('acme'); // runs the flow above
const { accessToken } = await app.accounts.getToken('acme');
const res = await fetch('https://api.example.com/v1/assets', { headers: { Authorization: `Bearer ${accessToken}` } });getToken always returns a fresh token (refreshed if needed). Don't store it — ask again when you need it.
#Checklist
- Public client, PKCE S256, redirect URI
https://chataway.co/apps/oauth/callback - Refresh tokens enabled
- Minimum scopes; explained in
permissions - Tools that act for the user declare
account - Your tools return a clear error for a rejected token ("reconnect Acme in the app's settings")