---
title: "Hub AI Capability Execution"
slug: hub-ai-capabilities
section: Globus Core
status: stable
description: "The Hub (BackOfficePrototype) runs every AI call through a database-backed capability registry."
last-updated: 2026-10-07
---

# Hub AI Capability Execution

## Overview

The Hub (BackOfficePrototype) runs every AI call through a database-backed capability registry. A capability is a row in `ai_capabilities` holding a key, prompt template, model, token and temperature settings and a provider. `executeAiCapability` in `BackOfficePrototype server/services/aiExecutionService.ts` looks the row up by `capabilityKey`, renders the prompt, calls the provider, and writes a row to `ai_invocation_logs`. The admin routes under `/api/ai/*` live in `server/routes/aiCapabilities.ts` (split into `aiCapabilities.capabilities.ts`, `aiCapabilities.execution.ts` and `aiCapabilities.providers.ts`). The four `/api/openai/*` text routes live in `server/routes/aiTextReview.ts`.

This is not the Nexus docs connector. For that, see [AI Connector](/docs/ai-connector). For where the AI layer sits in the Hub's capability layers, see [Capability Layers](/docs/globus-layers).

In-process callers of `executeAiCapability` include `aiExtraction.ts`, `enrollmentProofProcessor.ts`, `hostVerificationDeps.ts`, `housingLeadScoring.ts`, `translationSkills.ts`, `cbcTranslationOrchestrator.ts`, `workflowStepLibrary.ts` and `integrations.portalAi.ts`, as well as the admin routes below.

## Routes

All routes use `isAuthenticatedOrApiKey(...)` (session, or an API key holding every named scope) followed by a permission check. Success bodies use the standard `createSuccessResponse` envelope unless stated.

### Capability registry routes

| Verb | Path | API key scope | Permission | Notes |
|---|---|---|---|---|
| GET | `/api/ai/capabilities` | `workflows.read` | `programs.manage` | Returns all capabilities (ordered by name) as a page, each with `usageStats` (`invocationCount`, `avgDurationMs`; zeros when never invoked). |
| GET | `/api/ai/capabilities/:id` | `workflows.read` | `programs.manage` | Lookup by row id. Not found returns the standard not-found error. |
| POST | `/api/ai/capabilities` | `workflows.read`, `admin.write` | `programs.manage` | Body validated by `insertAiCapabilitySchema` plus `outputSchema` check. 201. Writes an `ai_capability` audit log (`created`). |
| PATCH | `/api/ai/capabilities/:id` | `workflows.read`, `admin.write` | `programs.manage` | Partial body. Audit log `updated`. |
| DELETE | `/api/ai/capabilities/:id` | `workflows.read`, `admin.write` | `programs.manage` | Hard delete, returns `success: true`. No audit log call in the handler. |
| POST | `/api/ai/capabilities/:id/version` | `workflows.read`, `admin.write` | `programs.manage` | Spreads the request body into the update and sets `version` to current plus 1. |
| POST | `/api/ai/capabilities/seed` | `workflows.read`, `admin.write` | `programs.manage` | Inserts each entry of `getCapabilitySeeds()` that does not exist yet; returns `seeded` with per-key `created` or `skipped`. A unique violation (`23505`) is reported as `skipped`. |

`outputSchema`, when present, must be an array of `key` (string), `type` (`string`, `number`, `boolean`, `date`, `json`, `array`) and optional `description`.

### Execute and invocation log routes

| Verb | Path | API key scope | Permission | Notes |
|---|---|---|---|---|
| POST | `/api/ai/execute` | `workflows.read`, `admin.write` | any of `programs.manage`, `dashboard.view` | Body: `capabilityKey` (string), `payload` (object), optional `contextOverrides`. A missing or non-string `capabilityKey` fails schema validation (400 `VALIDATION_ERROR`); a missing `payload` is rejected by the handler's own validation error. |
| GET | `/api/ai/invocations` | `workflows.read` | `programs.manage` | Query: optional `capabilityKey`, `limit` (default 50, no upper bound in the handler; `0` or a non-numeric value removes the limit). Newest first. |
| GET | `/api/ai/invocations/:capabilityKey` | `workflows.read` | `programs.manage` | Same handler logic with the key in the path. Used by the registry page "View Logs" panel. |

There is no route that fetches a single invocation by id. Both invocation routes return a page of log rows filtered by `capabilityKey` (all keys when the query parameter is omitted on the first route). Log rows include `inputPayload`, `outputPayload`, token counts, `status` and `errorMessage`, so `programs.manage` gates access to logged prompts and payloads.

### Provider settings routes

All provider routes require API key scope `admin.write` and permission `programs.manage`.

| Verb | Path | Behaviour |
|---|---|---|
| GET | `/api/ai/providers` | Lists `ai_provider_settings`. `apiKey` is masked (first 4 and last 4 characters around four dots; keys of 8 characters or fewer become eight dots) and `hasApiKey` is added. |
| PUT | `/api/ai/providers/:providerKey` | Upsert by `providerKey`. Body (all optional): `apiKey`, `displayName`, `baseUrl`, `isActive`, `defaultModel`. An empty `apiKey`, or one containing the mask characters, keeps the stored key. `isActive` is true unless explicitly `false`. `displayName` defaults to the `providerKey`, and an omitted `baseUrl` or `defaultModel` is stored as null (overwriting any previous value). Resets the cached OpenAI client, writes an audit log, and `auditMutation` action `ai_provider.updated`. |
| DELETE | `/api/ai/providers/:providerKey` | Deletes the row, resets the cached client, `auditMutation` action `ai_provider.deleted`. Missing row returns not-found. |
| POST | `/api/ai/providers/test` | Body requires `providerKey`, but the handler then runs the `text_enhancement` capability with a fixed payload regardless of which key was given. Returns `connected`, `model`, `durationMs`, `error` (null on success). Always HTTP 200 for a handled failure. |

### OpenAI text routes

Four POST routes in `aiTextReview.ts`, all with API key scope `portal.read` (named explicitly so read-only keys cannot invoke paid AI), permission `dashboard.view`, and `auditMutation` (entity type `ai_text_review`). Each calls `executeAiCapability` with the session user id.

| Path | Capability key | Required body | Audit action |
|---|---|---|---|
| `/api/openai/review-text` | `text_review` | `text` (string); optional `context`, `skillName`, `fieldName`, `hobbyName` | `ai.review_text` |
| `/api/openai/enhance-caption` | `text_enhancement` | `caption` (string); optional `context` | `ai.enhance_caption` |
| `/api/openai/review-showcase` | `text_review` | `captions` or `items` (non-empty array) | `ai.review_showcase` |
| `/api/openai/suggestions` | `field_suggestion` | `fieldName` (string); optional `context`, `currentValue` | `ai.suggestions` |

Of these four routes, only `review-text` checks the `ai_extraction_enabled` feature flag (503 with `success: false` when off; the seeded default is on). A missing or non-string required field fails schema validation first (400 `VALIDATION_ERROR` envelope); an empty string, or an empty `captions`/`items` array, returns 400 with `{ success: false, error }`. On a failed execution, an error string containing the lowercase text `not configured` returns 503; anything else returns 500 with a generic `INTERNAL_ERROR` envelope (`createSafeErrorResponse`). A JSON result is returned as `parsed`, else the fenced or raw text is parsed, else a fallback object (`feedback`, `enhancedCaption` or `suggestions`).

## Execution flow

`executeAiCapability` runs these steps:

1. Load the capability by `capabilityKey`. If none, return `success: false` with `Capability "<key>" not found or inactive`.
2. If `capability.provider` is a private GCP provider, delegate (below).
3. Otherwise get the OpenAI client; if none, return `AI_NOT_CONFIGURED`.
4. Render `promptTemplate` and `systemPrompt` by replacing double-brace word tokens with values from `payload` merged with `contextOverrides` (unknown tokens stay as written).
5. For `type` `vision` with `imageBase64` plus `mimeType`, or `imageUrl`, build an image message (GCS URLs and object paths are fetched and inlined as data URLs).
6. Call `chat.completions.create`. Models whose name starts with `gpt-5`, `o1`, `o3` or `o4` (case-insensitive) get `max_completion_tokens`; others get `max_tokens` and `temperature` (default 0.3). `maxTokens` defaults to 2000.
7. If `responseFormat` is `json`, request `json_object` output and `JSON.parse` the content into `parsed`.

### Provider routing

| `ai_capabilities.provider` | Backend | Falls back to OpenAI |
|---|---|---|
| `openai` (column default) | OpenAI chat completions | n/a |
| `PRIVATE_GCP` | Internal GCP Document AI passport OCR | No (code comment) |
| `PRIVATE_GCP_TEXT` | Internal GCP Document AI raw-text OCR (criminal background) | No (code comment) |
| `PRIVATE_GCP_TRANSLATION` | Internal GCP translation service | No (code comment) |

The private paths return an `error` string starting with `OCR_MISSING_DOCUMENT`, `OCR_DOCUMENT_NOT_RESOLVABLE` or `TRANSLATION_MISSING_TEXT` (followed by a colon and a message) when input is missing, or an image-fetch message when `imageUrl` cannot be read, and include a `provider` field in the result. The criminal background path logs only metadata, never `rawText`.

### OpenAI client resolution

The client is cached in memory and rebuilt after `resetOpenAIClient()` (called on provider upsert and delete). Credential order:

1. `ai_provider_settings` row with `providerKey` `openai`, when `isActive` and `apiKey` set (uses its `baseUrl`).
2. `OPENAI_API_KEY` with `OPENAI_BASE_URL`.
3. `AI_INTEGRATIONS_OPENAI_API_KEY` with `AI_INTEGRATIONS_OPENAI_BASE_URL`.

If none yields a key, the client is null and execution returns `AI_NOT_CONFIGURED`.

## Failure shapes

`/api/ai/execute` returns the raw `AiExecutionResult` on failure, not the standard error envelope: `success: false`, `error` (string), `model`, `durationMs`, and optionally `provider`.

| `error` value | HTTP status | Cause | Logged |
|---|---|---|---|
| `AI_NOT_CONFIGURED` | 503 | No OpenAI client (no active DB setting, no env keys) | No log row |
| Message containing `not found` | 404 | No capability with that key | No log row |
| `AI_MALFORMED_OUTPUT` | 500 | `responseFormat` is `json` and the model output failed `JSON.parse` | Yes, status `error`, message `AI_MALFORMED_OUTPUT: <parse message>` |
| Image fetch message (for example `Failed to fetch image from GCS URL`) | 500 | Vision capability whose `imageUrl` could not be resolved | No log row |
| Provider exception message | 500 | OpenAI or other error thrown | Yes, status `error` |
| `OCR_*`, `TRANSLATION_*` or adapter error | 500 | Private GCP path failure | Yes, status `error` (missing-input and adapter failures; an image-fetch failure on the private OCR path is logged too) |

These are plain top-level `error` strings on a `success: false` body. They are not `error.code` values in the envelope, and the HTTP status comes from the string checks in `aiCapabilities.execution.ts`. Callers that call `executeAiCapability` in-process receive the same result object and decide their own status.

## Configuration

| Variable | Default | Required | Purpose |
|---|---|---|---|
| `OPENAI_API_KEY` | none | Optional | Direct OpenAI key (GKE/prod path). Used when no active DB provider key exists. |
| `OPENAI_BASE_URL` | SDK default | Optional | Base URL paired with `OPENAI_API_KEY`. |
| `AI_INTEGRATIONS_OPENAI_API_KEY` | none | Optional | Managed proxy key (dev fallback). |
| `AI_INTEGRATIONS_OPENAI_BASE_URL` | SDK default | Optional | Base URL paired with the proxy key. |

At least one credential source is needed for `openai`-provider capabilities. The `ai_extraction_enabled` app setting gates only `/api/openai/review-text`.

## Data model

| Table | Key columns |
|---|---|
| `ai_capabilities` | `id`, `capability_key` (unique), `name`, `description`, `type` (default `chat`), `provider` (default `openai`), `prompt_template`, `system_prompt`, `model` (default `gpt-5-mini`), `temperature`, `max_tokens`, `response_format` (default `text`), `input_schema`, `output_schema`, `version`, `status` (default `active`), `created_by`, `updated_by` |
| `ai_invocation_logs` | `id`, `capability_key`, `capability_id` (FK to `ai_capabilities`), `model`, `input_payload`, `output_payload`, `prompt_tokens`, `completion_tokens`, `duration_ms`, `status` (default `success`), `error_message`, `user_id` (FK to `users`), `application_id`, `created_at`. Indexes on key, created time, application. |
| `ai_provider_settings` | `id`, `provider_key` (unique), `display_name`, `api_key`, `base_url`, `is_active` (default true), `default_model`, `updated_by`, timestamps |

The seed list in `server/routes/aiCapabilities.seeds.ts` includes keys such as `document_extraction`, `passport_extraction_gcp`, `text_review`, `text_enhancement`, `field_suggestion`, `document_translation`, `housing_lead_scoring` and `host_verification_analysis`. The migration history for models and token limits is in `migrations/` (for example `20261021000000_upgrade_gpt_4o_mini_to_gpt_5_mini.sql`).

### Invocation logging

`executeAiCapability` logs success and error paths fire-and-forget; a failed log write is swallowed and does not change the response. On success the log keeps the first 2000 characters of the prompt, the payload (with `imageBase64` replaced by a length marker), `contextOverrides`, the first 4000 characters of the result and the first 4000 characters of the stringified parsed JSON. On error it stores only `payloadKeys`. `application_id` is taken from `payload.applicationId` when it is a non-empty string, else null. `executeAiCapabilityDirect` logs the first 500 characters of the prompt and 1000 of the result, does not set `application_id`, does not log a malformed-JSON failure, and returns `OpenAI API key is not configured` rather than `AI_NOT_CONFIGURED` when there is no client.

## Behaviour and edge cases

- `getAiCapabilityByKey` does not filter by `status` (comment in `server/storage/aiStorage.ts`). `executeAiCapability` does not check `status`, so a capability that is not `active` still executes if its row exists.
- `GET /api/ai/providers` never returns the full key. A PUT that sends the masked value back keeps the stored key.
- `api_key` is stored as plain text in `ai_provider_settings`; no encryption is applied in the storage code.
- Capability edits and provider changes are audited; capability delete and seed are not audited in their handlers.

## Known gaps

- The 404 path in `/api/ai/execute` depends on the substring `not found` in the error text; a provider error message containing that text would also map to 404.
- The text routes test for lowercase `not configured`, but the service returns `AI_NOT_CONFIGURED`. By string comparison that does not match, so an unconfigured client on those four routes appears to surface as 500 INTERNAL_ERROR rather than 503. Not exercised against a running instance.
- `POST /api/ai/providers/test` ignores `providerKey` for the actual call.
- `limit` on the invocation routes is parsed with `parseInt` and not bounded.
- Per-capability rate limits and usage metrics described in the product-level [Capability Layers](/docs/globus-layers) page were not looked for here; only `invocationCount` and `avgDurationMs` are computed by the listing route.
