Chataway Developers
Concepts

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

plugin.json
json
{
  "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
idYour name for this account: lowercase letters, digits, _. Tools reference it with account.
labelShown on the Connect {label} button and in settings.
authorizeUrl, tokenUrlYour 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.
revokeUrlOptional. Called with the token when the user disconnects (RFC 7009).
clientIdYour public client id. Never a secret — the manifest is public.
scopesRequested scopes. Ask for the least you need; adding scopes later needs review and fresh consent.
requiredtrue → Chataway offers Connect right after the app is enabled. Otherwise it's offered the first time a tool needs it.
paramsExtra 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 (Mac) Browser Your OAuth server chataway.co relay 1 new state + verifier open authorizeUrl 2 sign in + consent 302 → redirect_uri?code&state 3 GET /apps/oauth/callback — “close this tab” 4 poll /api/apps/oauth/result?state=… { code } — once 5 POST tokenUrl: code + code_verifier access + refresh token → vault
  1. Chataway creates a random state and a PKCE code_verifier, and opens your authorizeUrl with response_type=code, your client_id, scope, state, code_challenge (S256) and redirect_uri=https://chataway.co/apps/oauth/callback.
  2. The user signs in to your service and approves.
  3. 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.
  4. The Mac that started the flow picks up the code (once — it's deleted when read).
  5. The Mac exchanges code + code_verifier at your tokenUrl directly — 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:

plugin.json
json
{
  "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 the scope from your WWW-Authenticate header, else your PRM's scopes_supported.
  • No registration endpoint? Add "clientId" — a public client you registered for the redirect above. Without either, the account can't be connected.
  • authorizeUrl / tokenUrl are ignored with auth: "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:

SettingValue
Client typePublic (no secret) — sometimes called "native" or "SPA"
Redirect URIhttps://chataway.co/apps/oauth/callback (exact match)
Grant typesauthorization_code, refresh_token
PKCERequired, S256
Token endpoint authnone
CORS on the token endpointNot 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:

http
POST /mcp HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
X-Chataway-Project: Qm9xY2Fyc3RhcnQ1bE2pX7kN0vR4sT8w
X-Chataway-Chat: 7Hk2LmPq0aZxY3vB9sR1tU4wQ6eD8fGh

Validate the token the way your API always does (introspection, JWT verification) and use it to find your user. With the helpers:

ts
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_in from your token response), with grant_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_in missing → 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:

ts
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")