Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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,
	}
}