Skip to main content
connector-generation 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.
跳到安装 Skills Marketplace 发现并探索由社区构建的 Agent Skills
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/codename-co/devs --skill connector-generation命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
下载 Zip 下载中... SOC
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 USERINFO_URL = 'https://{provider}.com/api/users/me'
interface RawItem {
id : string
name : string
}
interface ListResponse {
items : RawItem []
next_cursor ?: string
}
interface TokenResponse {
access_token : string
refresh_token ?: string
expires_in ?: number
scope : string
token_type : string
}
interface UserInfoResponse {
id : string
email ?: string
name ?: string
avatar ?: string
}
export class {Provider }Provider extends BaseAppConnectorProvider {
readonly id = '{provider-id}' as const
readonly config : ConnectorProviderConfig = {
id : '{provider-id}' ,
category : 'app' ,
name : '{Provider Name}' ,
icon : '{provider-icon}' ,
color : '#HEXCOLOR' ,
capabilities : ['read' , 'search' ],
supportedTypes : ['*' ],
maxFileSize : 10 * 1024 * 1024 ,
rateLimit : { requests : 100 , windowSeconds : 60 },
}
private get clientId (): string {
return import .meta .env .VITE_ {PROVIDER }_CLIENT_ID || ''
}
private get redirectUri (): string {
return `${window .location.origin} /oauth/callback`
}
getAuthUrl (state : string , codeChallenge : string ): string {
const params = new URLSearchParams ({
client_id : this .clientId ,
redirect_uri : this .redirectUri ,
response_type : 'code' ,
scope : 'read:user read:content' ,
state,
code_challenge : codeChallenge,
code_challenge_method : 'S256' ,
})
return `${AUTH_URL} ?${params.toString()} `
}
async exchangeCode (code : string , codeVerifier : string ): Promise <OAuthResult > {
const response = await fetch (`${BRIDGE_URL} /api/{provider}/oauth/token` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/x-www-form-urlencoded' ,
},
body : new URLSearchParams ({
code,
code_verifier : codeVerifier,
grant_type : 'authorization_code' ,
redirect_uri : this .redirectUri ,
}),
})
if (!response.ok ) {
const errorText = await response.text ()
throw new Error (`Token exchange failed: ${response.status} ${errorText} ` )
}
const data : TokenResponse = await response.json ()
return {
accessToken : data.access_token ,
refreshToken : data.refresh_token ,
expiresIn : data.expires_in ,
scope : data.scope ,
tokenType : data.token_type ,
}
}
async refreshToken (connector : Connector ): Promise <TokenRefreshResult > {
const refreshToken = await this .getDecryptedRefreshToken (connector)
if (!refreshToken) {
throw new Error ('No refresh token available' )
}
const response = await fetch (`${BRIDGE_URL} /api/{provider}/oauth/token` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/x-www-form-urlencoded' ,
},
body : new URLSearchParams ({
refresh_token : refreshToken,
grant_type : 'refresh_token' ,
}),
})
if (!response.ok ) {
const errorText = await response.text ()
throw new Error (`Token refresh failed: ${response.status} ${errorText} ` )
}
const data : TokenResponse = await response.json ()
return {
accessToken : data.access_token ,
expiresIn : data.expires_in ,
}
}
async validateToken (token : string ): Promise <boolean > {
try {
const response = await fetch (USERINFO_URL , {
headers : { Authorization : `Bearer ${token} ` },
})
return response.ok
} catch {
return false
}
}
async revokeAccess (connector : Connector ): Promise <void > {
const token = await this .getDecryptedToken (connector)
const response = await fetch (REVOKE_URL , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/x-www-form-urlencoded' ,
Authorization : `Bearer ${token} ` ,
},
})
if (!response.ok ) {
const errorText = await response.text ()
throw new Error (`Token revocation failed: ${response.status} ${errorText} ` )
}
}
async getAccountInfo (token : string ): Promise <AccountInfo > {
const response = await fetch (USERINFO_URL , {
headers : { Authorization : `Bearer ${token} ` },
})
if (!response.ok ) {
const errorText = await response.text ()
throw new Error (`Failed to get account info: ${response.status} ${errorText} ` )
}
const data : UserInfoResponse = await response.json ()
return {
id : data.id ,
email : data.email ,
name : data.name ,
picture : data.avatar ,
}
}
async list (connector : Connector , options ?: ListOptions ): Promise <ListResult > {
const params = new URLSearchParams ({
limit : String (options?.pageSize ?? 100 ),
})
if (options?.cursor ) {
params.set ('cursor' , options.cursor )
}
const url = `${API_BASE} /items?${params.toString()} `
const data = await this .fetchJson <ListResponse >(connector, url)
return {
items : data.items .map ((item ) => this .normalizeItem (item)),
nextCursor : data.next_cursor ,
hasMore : !!data.next_cursor ,
}
}
async listWithToken (token : string , options ?: ListOptions ): Promise <ListResult > {
const params = new URLSearchParams ({
limit : String (options?.pageSize ?? 100 ),
})
if (options?.cursor ) {
params.set ('cursor' , options.cursor )
}
const url = `${API_BASE} /items?${params.toString()} `
const data = await this .fetchJsonWithRawToken <ListResponse >(token, url)
return {
items : data.items .map ((item ) => this .normalizeItem (item)),
nextCursor : data.next_cursor ,
hasMore : !!data.next_cursor ,
}
}
async read (connector : Connector , externalId : string ): Promise <ContentResult > {
const url = `${API_BASE} /items/${encodeURIComponent (externalId)} /content`
const response = await this .fetchWithAuth (connector, url)
const content = await response.text ()
const mimeType = response.headers .get ('content-type' ) || 'text/plain'
return { content, mimeType }
}
async search (connector : Connector , query : string ): Promise <SearchResult > {
const url = `${API_BASE} /search?q=${encodeURIComponent (query)} `
const data = await this .fetchJson <ListResponse >(connector, url)
return {
items : data.items .map ((item ) => this .normalizeItem (item)),
totalCount : data.items .length ,
}
}
async getChanges (
connector : Connector ,
cursor : string | null ,
): Promise <ChangesResult > {
const params = new URLSearchParams ()
if (cursor) {
params.set ('cursor' , cursor)
}
const url = `${API_BASE} /changes?${params.toString()} `
const response = await this .fetchWithAuth (connector, url)
if (!response.ok ) {
const list = await this .list (connector)
return {
added : list.items ,
modified : [],
deleted : [],
newCursor : '' ,
hasMore : list.hasMore ,
}
}
const data = await response.json ()
return {
added : (data.added || []).map ((item : RawItem ) => this .normalizeItem (item)),
modified : (data.modified || []).map ((item : RawItem ) => this .normalizeItem (item)),
deleted : data.deleted || [],
newCursor : data.cursor || '' ,
hasMore : data.has_more || false ,
}
}
normalizeItem (rawItem : unknown ): ConnectorItem {
const item = rawItem as RawItem
return {
externalId : item.id ,
name : item.name ,
type : 'file' ,
mimeType : 'text/plain' ,
path : `/${item.name} ` ,
lastModified : new Date (),
}
}
}
export default new {Provider }Provider ()
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' , {PROVIDER }_CLIENT_SECRET)
try {
const response = await fetch ('https://{provider}.com/oauth/token' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/x-www-form-urlencoded' },
body : params.toString (),
})
const responseBody = await response.text ()
res.writeHead (response.status , {
...CORS_HEADERS ,
'Content-Type' : response.headers .get ('content-type' ) || 'application/json' ,
})
res.end (responseBody)
return
} catch (err) {
logger.error (`{Provider} proxy error: ${err.message} ` )
res.writeHead (502 , CORS_HEADERS )
res.end (JSON .stringify ({ error : 'Proxy error' , message : err.message }))
return
}
}
const targetUrl = `https://api.{provider}.com/v1/${{provider}Path} ${url.search} `
return proxyRequest (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}/ , '/v1' ),
},
},
}
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) 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
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