← Public packages
@kody/blandPublic
src/core.ts
335 lines · 9.9 KB · TypeScript/**
* Shared Bland.ai transport: API-key auth via secret placeholders, dry-run
* mutations, and setup-aware errors.
*
* Auth: `Authorization: Bearer {{secret:blandApiKey|scope=user}}` (bare key
* also accepted by Bland). Optional BYOT Twilio uses `encrypted_key` from
* `blandEncryptedKey` when present.
*
* Docs: https://docs.bland.ai/ — agent lifecycle https://docs.bland.ai/platform/agent-quickstart.md
*/
export const API_BASE_URL = 'https://api.bland.ai'
export const API_HOST = 'api.bland.ai'
export const DASHBOARD_SETTINGS_URL = 'https://app.bland.ai/dashboard/settings'
export const DOCS_URL = 'https://docs.bland.ai/'
export const AGENT_QUICKSTART_URL = 'https://docs.bland.ai/platform/agent-quickstart.md'
export const MCP_URL = 'https://api.bland.ai/v1/mcp'
export const DEFAULT_API_KEY_SECRET = 'blandApiKey'
export const DEFAULT_ENCRYPTED_KEY_SECRET = 'blandEncryptedKey'
export const SETTINGS_DEFAULT_VOICE = 'defaultVoice'
const API_KEY_PATTERN = /^blandApiKey(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ENCRYPTED_KEY_PATTERN = /^blandEncryptedKey(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
export type BlandAuthInput = {
apiBaseUrl?: string
/** Extra app label. `work` reads `blandApiKey-work`. */
account?: string
apiKeySecret?: string
encryptedKeySecret?: string
/**
* When true, attach the BYOT `encrypted_key` header from the encrypted-key
* secret (or skip if that secret is unset and you only want Bland numbers).
*/
useEncryptedKey?: boolean
}
export type BlandObject = Record<string, any>
export class BlandApiError extends Error {
status: number
code: string | null
details: unknown
headers: Record<string, string>
setup: {
apiKeyUrl: string
hosts: string[]
dashboard: string
docs: string
}
constructor(
message: string,
input: {
status: number
code?: string | null
details?: unknown
headers?: Record<string, string>
auth?: BlandAuthInput
},
) {
super(message)
this.name = 'BlandApiError'
this.status = input.status
this.code = input.code ?? null
this.details = input.details ?? null
this.headers = input.headers ?? {}
this.setup = {
apiKeyUrl: apiKeySetupUrl(resolveApiKeySecretName(input.auth)),
hosts: [API_HOST],
dashboard: DASHBOARD_SETTINGS_URL,
docs: DOCS_URL,
}
}
}
export function parseAction<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: T,
label: string,
): T {
const action = (value == null || value === '' ? fallback : value) as unknown
if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
return action as T
}
throw new Error(
'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
)
}
function accountLabel(value: string | undefined): string | null {
const trimmed = (value ?? '').trim()
if (!trimmed || trimmed === 'default') return null
if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
throw new Error(
'account must be a short label such as "work" or "sandbox" (letters, numbers, _ or -).',
)
}
return trimmed
}
function suffixName(base: string, account: string | null): string {
return account ? base + '-' + account : base
}
function assertSecretName(name: string, pattern: RegExp, expected: string): string {
if (!pattern.test(name)) {
throw new Error(
'Secret name must match ' + expected + ' (optional -label). Got: ' + JSON.stringify(name),
)
}
return name
}
export function resolveApiKeySecretName(input?: BlandAuthInput): string {
if (input?.apiKeySecret != null && String(input.apiKeySecret).trim() !== '') {
return assertSecretName(String(input.apiKeySecret).trim(), API_KEY_PATTERN, DEFAULT_API_KEY_SECRET)
}
return suffixName(DEFAULT_API_KEY_SECRET, accountLabel(input?.account))
}
export function resolveEncryptedKeySecretName(input?: BlandAuthInput): string {
if (input?.encryptedKeySecret != null && String(input.encryptedKeySecret).trim() !== '') {
return assertSecretName(
String(input.encryptedKeySecret).trim(),
ENCRYPTED_KEY_PATTERN,
DEFAULT_ENCRYPTED_KEY_SECRET,
)
}
return suffixName(DEFAULT_ENCRYPTED_KEY_SECRET, accountLabel(input?.account))
}
function secretsNewUrl(name: string, description: string): string {
const q =
'name=' +
encodeURIComponent(name) +
'&description=' +
encodeURIComponent(description) +
'&allowedHosts=' +
encodeURIComponent(API_HOST) +
'&scope=user'
return 'https://kody.codes/account/secrets/new?' + q
}
export function apiKeySetupUrl(name = DEFAULT_API_KEY_SECRET): string {
return secretsNewUrl(name, 'Bland.ai API key from app.bland.ai/dashboard/settings')
}
export function encryptedKeySetupUrl(name = DEFAULT_ENCRYPTED_KEY_SECRET): string {
return secretsNewUrl(
name,
'Bland BYOT Twilio encrypted_key (only if using your own Twilio account)',
)
}
export function setupUrls(auth?: BlandAuthInput) {
return {
apiKeyUrl: apiKeySetupUrl(resolveApiKeySecretName(auth)),
encryptedKeyUrl: encryptedKeySetupUrl(resolveEncryptedKeySecretName(auth)),
hosts: [API_HOST] as string[],
dashboard: DASHBOARD_SETTINGS_URL,
docs: DOCS_URL,
agentQuickstart: AGENT_QUICKSTART_URL,
mcpUrl: MCP_URL,
}
}
function apiKeyPlaceholder(input?: BlandAuthInput): string {
return '{{secret:' + resolveApiKeySecretName(input) + '|scope=user}}'
}
function encryptedKeyPlaceholder(input?: BlandAuthInput): string {
return '{{secret:' + resolveEncryptedKeySecretName(input) + '|scope=user}}'
}
export function encodeQuery(
params: Record<string, string | number | boolean | undefined | null>,
): string {
const parts: string[] = []
for (const [key, value] of Object.entries(params)) {
if (value == null || value === '') continue
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(value)))
}
return parts.length ? '?' + parts.join('&') : ''
}
function headersToObject(headers: Headers): Record<string, string> {
const out: Record<string, string> = {}
headers.forEach((value, key) => {
out[key] = value
})
return out
}
function isMutation(method: string): boolean {
return method !== 'GET' && method !== 'HEAD'
}
export type BlandRequestResult<T = BlandObject> = {
ok: true
status: number
data: T
headers: Record<string, string>
}
export type BlandRequestInput = BlandAuthInput & {
method?: string
path: string
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
/** When true, return the preview without calling Bland. */
dryRun?: boolean
/**
* Mutations (POST/PUT/PATCH/DELETE) default to dry-run. Pass confirm: true
* to execute live. GET/HEAD never require confirm.
*/
confirm?: boolean
}
/**
* Authenticated request to Bland (`api.bland.ai`).
* Mutations are dry-run unless `confirm: true`.
*/
export async function blandRequest<T = BlandObject>(
input: BlandRequestInput,
): Promise<BlandRequestResult<T> | { ok: true; dryRun: true; preview: BlandObject }> {
const method = String(input.method ?? 'GET').toUpperCase()
const path = String(input.path ?? '').trim()
if (!path.startsWith('/')) {
throw new Error('path must start with / (for example /v1/calls).')
}
const base = String(input.apiBaseUrl ?? API_BASE_URL).replace(/\/+$/, '')
const url = base + path + encodeQuery(input.query ?? {})
const host = new URL(url).host
if (host !== API_HOST && !String(input.apiBaseUrl ?? '').trim()) {
throw new Error('Bland requests must stay on api.bland.ai unless apiBaseUrl is set deliberately.')
}
const dryRunExplicit = input.dryRun === true
const confirm = input.confirm === true
const shouldDryRun =
dryRunExplicit || (isMutation(method) && !confirm && input.dryRun !== false)
const apiKeySecret = resolveApiKeySecretName(input)
const encryptedKeySecret = resolveEncryptedKeySecretName(input)
const attachEncrypted = input.useEncryptedKey === true
if (shouldDryRun) {
return {
ok: true,
dryRun: true,
preview: {
method,
url,
auth: 'Authorization: Bearer secret:' + apiKeySecret,
encryptedKey: attachEncrypted ? 'secret:' + encryptedKeySecret : null,
body: input.body ?? null,
setup: setupUrls(input),
note:
isMutation(method) && !confirm
? 'Mutation preview only. Pass confirm: true to call Bland.'
: 'dryRun preview — no network call.',
},
}
}
const headers: Record<string, string> = {
Authorization: 'Bearer ' + apiKeyPlaceholder(input),
Accept: 'application/json',
}
if (attachEncrypted) {
headers['encrypted_key'] = encryptedKeyPlaceholder(input)
}
let bodyText: string | undefined
if (input.body !== undefined && input.body !== null && method !== 'GET' && method !== 'HEAD') {
headers['Content-Type'] = 'application/json'
bodyText = JSON.stringify(input.body)
}
const response = await fetch(url, { method, headers, body: bodyText })
const responseHeaders = headersToObject(response.headers)
const text = await response.text()
let parsed: unknown = null
if (text) {
try {
parsed = JSON.parse(text)
} catch {
parsed = { raw: text }
}
}
if (!response.ok) {
const obj = parsed && typeof parsed === 'object' ? (parsed as BlandObject) : null
const code =
(obj && (obj.code || obj.error || obj.error_code || obj.status)) != null
? String(obj.code || obj.error || obj.error_code || obj.status)
: null
const message =
(obj && (obj.message || obj.error || obj.detail || obj.errors)) != null
? typeof obj.message === 'string'
? obj.message
: typeof obj.error === 'string'
? obj.error
: typeof obj.detail === 'string'
? obj.detail
: 'Bland API error ' + response.status
: 'Bland API error ' + response.status
const hint =
response.status === 401 || response.status === 403
? ' Save blandApiKey at ' +
apiKeySetupUrl(apiKeySecret) +
' and approve host ' +
API_HOST +
'.'
: ''
throw new BlandApiError(message + hint, {
status: response.status,
code,
details: parsed,
headers: responseHeaders,
auth: input,
})
}
return {
ok: true,
status: response.status,
data: (parsed ?? {}) as T,
headers: responseHeaders,
}
}