| name | connector-generation |
| description | Guide for creating new external service connectors in DEVS. Use this when asked to add a new OAuth app connector, API connector, or integration with external services like Slack, Trello, Linear, Asana, Dropbox, GitHub, etc. |
Connector Generation for DEVS
When creating new connectors for external services in DEVS, follow this comprehensive guide covering OAuth flow, provider implementation, registration, bridge proxy setup, and UI integration.
Overview
DEVS connectors enable synchronizing content from external services into the Knowledge Base. The architecture consists of:
- Provider Implementation - OAuth + API integration in
src/features/connectors/providers/apps/
- Provider Registry - Lazy loading registration in
provider-registry.ts
- OAuth Gateway - OAuth config for popup-based auth flow
- Bridge Server - Server-side proxy for OAuth secrets and CORS (in
utils/devs-bridge/)
- UI Configuration - Icons and display metadata
Step 1: Define Provider Type
Add the new provider to the type union in src/features/connectors/types.ts:
export type AppConnectorProvider =
| 'google-drive'
| 'gmail'
| 'google-calendar'
| 'google-meet'
| 'google-tasks'
| 'notion'
| 'dropbox'
| 'github'
| 'qonto'
| 'slack'
Also add the config entry in APP_CONNECTOR_CONFIGS:
slack: {
id: 'slack',
category: 'app',
name: 'Slack',
icon: 'slack',
color: '#4A154B',
capabilities: ['read', 'search'],
supportedTypes: ['message', 'channel', 'file'],
maxFileSize: 10 * 1024 * 1024,
rateLimit: { requests: 50, windowSeconds: 60 },
},
Step 2: Create Provider Implementation
Create a new file src/features/connectors/providers/apps/{provider}.ts:
import { BRIDGE_URL } from '@/config/bridge'
import { BaseAppConnectorProvider } from '../../connector-provider'
import type {
Connector,
ConnectorProviderConfig,
OAuthResult,
TokenRefreshResult,
AccountInfo,
ListOptions,
ListResult,
ContentResult,
SearchResult,
ChangesResult,
ConnectorItem,
} from '../../types'
const API_BASE = `${BRIDGE_URL}/api/{provider}`
const AUTH_URL = 'https://{provider}.com/oauth/authorize'
const TOKEN_URL = 'https://{provider}.com/oauth/token'
const REVOKE_URL = 'https://{provider}.com/oauth/revoke'
const =
{
:
:
}
{
: []
?:
}
{
:
?:
?:
:
:
}
{
:
?:
?:
?:
}
{} {
id =
: = {
: ,
: ,
: ,
: ,
: ,
: [, ],
: [],
: * * ,
: { : , : },
}
(): {
...{}_CLIENT_ID ||
}
(): {
}
(: , : ): {
params = ({
: .,
: .,
: ,
: ,
state,
: codeChallenge,
: ,
})
}
(: , : ): <> {
response = (, {
: ,
: {
: ,
},
: ({
code,
: codeVerifier,
: ,
: .,
}),
})
(!response.) {
errorText = response.()
()
}
: = response.()
{
: data.,
: data.,
: data.,
: data.,
: data.,
}
}
(: ): <> {
refreshToken = .(connector)
(!refreshToken) {
()
}
response = (, {
: ,
: {
: ,
},
: ({
: refreshToken,
: ,
}),
})
(!response.) {
errorText = response.()
()
}
: = response.()
{
: data.,
: data.,
}
}
(: ): <> {
{
response = (, {
: { : },
})
response.
} {
}
}
(: ): <> {
token = .(connector)
response = (, {
: ,
: {
: ,
: ,
},
})
(!response.) {
errorText = response.()
()
}
}
(: ): <> {
response = (, {
: { : },
})
(!response.) {
errorText = response.()
()
}
: = response.()
{
: data.,
: data.,
: data.,
: data.,
}
}
(: , ?: ): <> {
params = ({
: (options?. ?? ),
})
(options?.) {
params.(, options.)
}
url =
data = .<>(connector, url)
{
: data..( .(item)),
: data.,
: !!data.,
}
}
(: , ?: ): <> {
params = ({
: (options?. ?? ),
})
(options?.) {
params.(, options.)
}
url =
data = .<>(token, url)
{
: data..( .(item)),
: data.,
: !!data.,
}
}
(: , : ): <> {
url =
response = .(connector, url)
content = response.()
mimeType = response..() ||
{ content, mimeType }
}
(: , : ): <> {
url =
data = .<>(connector, url)
{
: data..( .(item)),
: data..,
}
}
(
: ,
: | ,
): <> {
params = ()
(cursor) {
params.(, cursor)
}
url =
response = .(connector, url)
(!response.) {
list = .(connector)
{
: list.,
: [],
: [],
: ,
: list.,
}
}
data = response.()
{
: (data. || []).( .(item)),
: (data. || []).( .(item)),
: data. || [],
: data. || ,
: data. || ,
}
}
(: ): {
item = rawItem
{
: item.,
: item.,
: ,
: ,
: ,
: (),
}
}
}
{}()
Step 3: Register Provider
3a. Add to Provider Registry
In src/features/connectors/provider-registry.ts, add the lazy loader:
static initializeDefaults(): void {
this.register('{provider-id}', () => import('./providers/apps/{provider}'))
}
const APP_PROVIDERS: readonly AppConnectorProvider[] = [
'google-drive',
'gmail',
'google-calendar',
'google-tasks',
'notion',
'qonto',
'{provider-id}',
] as const
3b. Add to Provider Index
In src/features/connectors/providers/apps/index.ts:
'{provider-id}': {
name: '{Provider Name}',
icon: '{IconName}',
color: '#HEXCOLOR',
description: 'Sync content from {Provider}',
syncSupported: true,
},
export const AVAILABLE_PROVIDERS: AppConnectorProvider[] = [
'{provider-id}',
]
export const {provider} = () => import('./{provider}')
Step 4: Configure OAuth Gateway
In src/features/connectors/oauth-gateway.ts, add OAuth config:
const OAUTH_CONFIGS: Record<string, OAuthConfig> = {
'{provider-id}': {
authUrl: 'https://{provider}.com/oauth/authorize',
tokenUrl: `${BRIDGE_URL}/api/{provider}/oauth/token`,
clientId: import.meta.env.VITE_{PROVIDER}_CLIENT_ID || '',
clientSecret: '',
scopes: [
'read:user',
'read:content',
],
pkceRequired: true,
useBasicAuth: false,
},
}
Step 5: Add Provider Scopes
In src/features/connectors/connector-provider.ts, add scopes:
const PROVIDER_SCOPES: Record<AppConnectorProvider, string[]> = {
'{provider-id}': ['read:user', 'read:content'],
}
Step 6: Configure Bridge Server (Production)
Add proxy route to utils/devs-bridge/server.mjs:
const {PROVIDER}_CLIENT_ID = process.env.{PROVIDER}_CLIENT_ID || ''
const {PROVIDER}_CLIENT_SECRET = process.env.{PROVIDER}_CLIENT_SECRET || ''
if (path.startsWith('/api/{provider}/')) {
const {provider}Path = path.replace('/api/{provider}/', '')
if ({provider}Path.startsWith('oauth/token')) {
logger.info('{Provider} OAuth token request (injecting credentials)')
const chunks = []
for await (const chunk of req) {
chunks.push(chunk)
}
const originalBody = Buffer.concat(chunks).toString()
const params = new URLSearchParams(originalBody)
params.set('client_id', {PROVIDER}_CLIENT_ID)
params.set(, {}_CLIENT_SECRET)
{
response = (, {
: ,
: { : },
: params.(),
})
responseBody = response.()
res.(response., {
...,
: response..() || ,
})
res.(responseBody)
} (err) {
logger.()
res.(, )
res.(.({ : , : err. }))
}
}
targetUrl =
(req, res, targetUrl)
}
Update utils/devs-bridge/.env.example:
# {Provider} OAuth
{PROVIDER}_CLIENT_ID=
{PROVIDER}_CLIENT_SECRET=
Step 7: Configure Vite Dev Proxy (Development)
In vite.config.ts, add dev proxy:
server: {
proxy: {
'/api/{provider}/oauth': {
target: 'https://{provider}.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/{provider}\/oauth/, '/oauth'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
if (req.url?.includes('/token')) {
const clientId = env.VITE_{PROVIDER}_CLIENT_ID || ''
const clientSecret = env.VITE_{PROVIDER}_CLIENT_SECRET || ''
}
})
},
},
'/api/{provider}': {
target: 'https://api.{provider}.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/{provider}/, ),
},
},
}
Step 8: Add Icon
Add the provider icon to src/components/Icon.tsx:
import { {Provider}Icon } from './icons/{provider}'
export const ICONS: Record<IconName, ComponentType<IconProps>> = {
{Provider}: {Provider}Icon,
}
export type IconName =
|
| '{Provider}'
Step 9: Add Environment Variables
Create or update .env.local:
VITE_{PROVIDER}_CLIENT_ID=your_client_id_here
VITE_{PROVIDER}_CLIENT_SECRET=your_client_secret_here
Step 10: Add Normalizer Mappings (if needed)
If the provider has custom MIME types, add to src/features/connectors/normalizer.ts:
export const PROVIDER_FILE_TYPES: Record<
AppConnectorProvider,
Record<string, 'document' | 'image' | 'text'>
> = {
'{provider-id}': {
'application/vnd.{provider}.document': 'document',
'application/vnd.{provider}.message': 'text',
},
}
Provider-Specific Considerations
No PKCE Support
If the provider doesn't support PKCE (like Notion):
getAuthUrl(state: string, _codeChallenge: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
state,
})
return `${AUTH_URL}?${params.toString()}`
}
Basic Auth for Token Exchange (like Notion)
If the provider requires Basic auth:
async exchangeCode(code: string, _codeVerifier: string): Promise<OAuthResult> {
const basicAuth = btoa(`${this.clientId}:${this.clientSecret}`)
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
Authorization: `Basic ${basicAuth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: this.redirectUri,
}),
})
}
Non-Expiring Tokens (like Notion)
If tokens don't expire:
async refreshToken(_connector: Connector): Promise<TokenRefreshResult> {
throw new Error('{Provider} tokens do not expire. Re-authenticate if needed.')
}
Custom Headers
If the provider requires custom headers (like Notion-Version):
protected override async fetchWithAuth(
connector: Connector,
url: string,
options: RequestInit = {},
): Promise<Response> {
const token = await this.getDecryptedToken(connector)
const headers = new Headers(options.headers)
headers.set('Authorization', `Bearer ${token}`)
headers.set('{Provider}-Version', '2024-01-01')
return fetch(url, { ...options, headers })
}
File Checklist
When adding a new connector, ensure you've modified:
Testing
Test the new connector:
- Start dev server:
npm run dev
- Go to Connectors page:
/connectors
- Click "Add Connector" and select the new provider
- Complete OAuth flow
- Verify content listing works
- Test sync functionality
Common Issues
- CORS errors: Ensure bridge proxy is configured correctly
- OAuth callback fails: Verify redirect_uri matches exactly in provider's OAuth app settings
- Token exchange fails: Check client credentials are correctly injected
- API requests fail: Verify API base URL and authentication header format