# AI Agent Module — Agent Context BFF for **Sammy 2.0**, the AI agent hosted in the external **Meeseeks** service (`humand-packages` sibling repo, but separate gateway). This module is a **stateless proxy** — no Postgres tables, no Sequelize, no domain logic of its own. Every backoffice endpoint resolves the Meeseeks `agentId` for the caller's instance and forwards to the equivalent Meeseeks endpoint. **After finishing any implementation task, evaluate:** - New Meeseeks endpoint added or contract change → §2 + §4 - Agent resolution / caching strategy changed → §3 - New env var, community feature, or DI port → §5 + §6 - Auth between Humand and Meeseeks added or changed → §7 - New deferred item or open question → §7 If any apply → **ask the user** if they want to update AGENTS.md (never update automatically). If none apply, skip silently. --- ## 1. Purpose & Scope - Frontend at `/admin/...` consumes these endpoints to manage Sammy's knowledge base, segmentations, and configuration. - "Sammy 1.0" was the older Voiceflow integration in the `aiChatbot` module — being deprecated. Do not confuse the two. - The **agent itself** lives in Meeseeks. Knowledge files, embeddings, segmentations, and message history are stored on the Meeseeks side (S3 + a `documents` table in their own Postgres). This module never persists user content. **Out of scope (do not build here):** - Direct database access — none of it. There are no DAOs and no migrations from this module. - Bot user / `humandApiKey` provisioning — handled manually by the Meeseeks team for now (per their runbook). - MCP server for agent actions (vacations, etc.) — Sammy 2.0 does Q&A only. When tools land, register an MCP via Meeseeks `tools.agentMcpServers` config; do not add it here without a new design discussion. - Webhook for chat — Meeseeks polls Humand instead (`webhookEnabled: false`). --- ## 2. Meeseeks Contract Single source of truth: `meeseeks/docs/agent-tab-gateway-api.md` in the sibling Meeseeks repo. Endpoints exposed by Meeseeks Gateway: ``` GET /agents/:instanceId list agents POST /agents/:instanceId create agent DELETE /agents/:instanceId/:agentId delete agent GET /agents/:instanceId/:agentId get config PUT /agents/:instanceId/:agentId update config GET /agents/:instanceId/:agentId/files?folder=&search=&... list files (paged, folder-scoped) GET /agents/:instanceId/:agentId/files/:filename file detail PUT /agents/:instanceId/:agentId/files/:filename?userId=N upload (stream body) DELETE /agents/:instanceId/:agentId/files/:filename delete (async, 202) DELETE /agents/:instanceId/:agentId/files batch delete PATCH /agents/:instanceId/:agentId/files/:filename rename / move file POST /agents/:instanceId/:agentId/folders create empty folder DELETE /agents/:instanceId/:agentId/folders/:path delete folder + descendants (async) PATCH /agents/:instanceId/:agentId/folders/:path rename / move folder PUT /agents/:instanceId/:agentId/segmentations batch update tags + userIds GET /agents/:instanceId/:agentId/users/:userId/visible-documents (currently unused by BFF) ``` Important contract details: - Tags use `(groupId, itemId)` from Humand's segmentations system. The frontend lists groups/items via `/backoffice/segmentation-groups/lite` (existing) and assigns them via our `/bo/ai-agent/segmentations` proxy. - File paths are URL-encoded; subfolders use `%2F` for `/`. - KB list is **eventually consistent** — webapp polls `GET /files` every 3s while open. Per-row `status` transitions `queued` → `processing` → `ready` (or `failed`). - Uploads accept up to 50 MB. Allowed file types: PDF, DOCX, XLSX, PPTX, Markdown (md/txt), AsciiDoc, LaTeX, HTML, CSV. - `AgentFileRow.id` is a **stable ULID** that survives rename/move (Meeseeks keys S3 objects by ULID, not by name). Useful for optimistic UI updates if needed. --- ## 3. Architecture ``` HTTP req (backoffice JWT) └── AiAgentBackofficeController (presentation/controllers) └── AiAgentService (business/services) — orchestrator ├── AgentResolverService (business/services) — resolves agentId by instance with 5-min in-memory cache └── MeeseeksClientPort (business/ports) └── MeeseeksClientAdapter (infrastructure/adapters) — axios, error mapping, streaming uploads ``` **No DAOs, no DB layer.** All state is in Meeseeks; this module is a pure transport. The only in-memory state is the agent resolver cache. ### Agent resolution Meeseeks's API path is `/agents/:instanceId/:agentId`, so the BFF needs to know which `agentId` corresponds to "Sammy" for the caller's `instanceId`. **By convention, the agent name is `'sammy'`** (`SAMMY_AGENT_NAME` constant in `business/constants.ts`). Resolution flow (`AgentResolverService.resolveAgentId`): 1. Check in-memory cache by `instanceId`. Hit + not expired → return. 2. Miss: call Meeseeks `GET /agents/:instanceId` with the admin's Bearer JWT. 3. Find the agent with `name.toLowerCase() === 'sammy'` (case-insensitive — tolerates both `"Sammy"` and `"sammy"` as registered by the Meeseeks team). 4. Cache for `AGENT_RESOLVER_CACHE_TTL_MS` (5 min) and return. 5. No matching agent for that instance → throw `NotFoundError`. `invalidate(instanceId)` is exposed but currently unused. Wire it up if Meeseeks ever notifies us of agent renames/deletes. **Why this design (no `ai_agent_binding` table):** the convention-by-name approach was chosen over an explicit mapping table to avoid a manual SQL step in the Meeseeks team's activation runbook. Tradeoff: the string `'sammy'` is the contract — if Meeseeks ever renames it, this module breaks fast (and visibly). ### Streaming uploads `PUT /bo/ai-agent/files/:filename` reads the request body as a `Readable` and forwards it directly to axios via `data: req`. **Do not buffer the file** — the controller passes `req` straight through. The adapter sets `maxBodyLength: Infinity` and `maxContentLength: Infinity` on the axios call. `?userId=` is **injected by the BFF** from `loggedUser.getId()` — the frontend does not pass it. This populates `AgentFileRow.uploadedBy` so the UI can show "Subido por: Sofía López". ### Error mapping `MeeseeksClientAdapter.mapError` translates Meeseeks's `{ error: string }` envelope into Humand error classes: | Meeseeks status | Humand error | Used `ErrorCodes` | |---|---|---| | 400 | `BadRequestError` | `BAD_REQUEST` | | 401 | `UnauthorizedError` | `ACCESS_DENIED` | | 404 | `NotFoundError` | `NOT_FOUND` | | 409 | `RequestConflictError` | `ENTITY_ALREADY_EXIST` | | 422 | `RequestUnprocessableEntityError` | `INVALID_DATA` | | 5xx / network / unknown | `InternalServerError` | `INTERNAL_SERVER_ERROR` | ### Auth forwarding Every method on `MeeseeksClientPort` takes an `authToken: string` as its last positional arg. The adapter attaches it as `Authorization: Bearer ${authToken}` on every outbound request. The controller extracts the token from `req.headers.authorization` (stripping the `Bearer ` prefix) and passes it through the service → resolver → adapter chain. Meeseeks accepts three token types on this admin surface (Janus M2M, Janus backoffice user, legacy Humand ACCESS with `scope` ⊇ `BO`); the monolith BO token that already reaches the BFF works as-is. `ENTITY_ALREADY_EXIST` and `INVALID_DATA` are reused codes — there are no module-specific codes today. If analytics/i18n need finer granularity, add `AI_AGENT_*` codes to `ErrorCodes` and update the switch. --- ## 4. HTTP Surface All routes mounted at `/ai-agent` under the backoffice private router (`backofficeRoot.ts`) — gated by `validateAccessToken` + `validateBackofficeScope` + `PermissionsService.validateManageAiAgents` (called from every controller handler before any business logic). The capability is `MANAGE_AI_AGENTS`; defined in `@humand-packages/common/PermissionCapabilityNames`, registered in Cerberus as `FEATURE_DEPENDENT` on the `CHATS_V2_SAMMY_ASSISTANT` feature flag. | Method | Path | VC | Notes | |--------|------|----|-------| | GET | `/bo/ai-agent/config` | — | Get current Sammy config | | PUT | `/bo/ai-agent/config` | `UpdateAgentConfigVC` | Editable fields only: `description`, `identity`, `goal`, `instructions`, `helloMessage`, `inactivityMessage`, `inactivityEnabled`, `inactivityTimeoutSec`, `audienceFilterEnabled`. `name` is intentionally not editable (bot user identity). Processing messages (`searchingMessage`/`readingMessage`) are managed automatically by Meeseeks — not exposed. The system prompt preview (`GET /system-prompt` in Meeseeks) is also not exposed — product decision (2026-04-30 meeting). | | GET | `/bo/ai-agent/files` | `ListFilesQueryVC` | Query params: `folder`, `limit`, `offset`, `search`. Search is recursive across the subtree under `folder` (Meeseeks side). | | GET | `/bo/ai-agent/files/:filename` | — | Filename URL-encoded | | PUT | `/bo/ai-agent/files/:filename` | `UploadSegmentationVC` (optional) | **Two modes**, picked by `Content-Type`. **Multipart** (`multipart/form-data`): parts are `file` (binary, required) and optional `segmentation` (JSON string `{ tags?, userIds? }`). BFF parses with `getHumandUpload()` (`multer` memoryStorage, 50 MB cap), validates `segmentation` with `class-validator`, uploads to Meeseeks, then applies segmentation. **On segmentation failure → DELETE the file in Meeseeks (cleanup-on-fail) and rethrow.** **Raw bytes** (any other `Content-Type`): legacy streaming path — pipes `req` straight to Meeseeks, no segmentation. Both modes inject `?userId=loggedUser.getId()`. Response: `{ filename, segmentationId, segmentationApplied: true \| false \| null }` (`true` = applied, `false` = empty segmentation field, `null` = no metadata sent). The `segmentation` part **must come before** the `file` part in the multipart body so the parser sees it before kicking off the upload. | | DELETE | `/bo/ai-agent/files/:filename` | — | Returns 202 + `messageId` | | DELETE | `/bo/ai-agent/files` | `BatchDeleteFilesVC` | Body `{ filenames: string[] }`. 1–500 entries | | PATCH | `/bo/ai-agent/files/:filename` | `RenameFileVC` | Body `{ newName?, newFolder? }`. Synchronous | | POST | `/bo/ai-agent/folders` | `CreateFolderVC` | Body `{ path: string }` — must end with `/`. 201 | | DELETE | `/bo/ai-agent/folders/:path` | — | Recursive, 202 | | PATCH | `/bo/ai-agent/folders/:path` | `RenameFolderVC` | Body `{ newName?, newParent? }`. Synchronous | | PUT | `/bo/ai-agent/segmentations` | `BatchUpdateSegmentationsVC` | Body `{ documents, tags, userIds }`. Wholesale replace — Meeseeks overwrites whatever each doc had. The front composes the new full set client-side per doc. | | GET | `/bo/ai-agent/folders/tree` | — | Returns `{ folders: Array<{ path, parentPath }> }` covering every folder under the agent root. BFS over Meeseeks `GET /files` with in-memory cache (60 s TTL). Front arma the visual tree from the flat list. Route is registered **before** `/folders/:path` so Express matches the static path first. | | GET | `/bo/ai-agent/files/:filename/audience-count` | — | Returns `{ count, isAll }` — number of users effectively reached by the document's segmentation, and whether the doc is open to everyone. Lazy: front calls only when the admin opens the row's detail. BFF fetches the row from Meeseeks (for `segmentationTags` + `segmentationUserIds`), composes a `SegmentationPayload` and delegates to `AudienceCountPort` (wraps `AudiencesService.getUsersAudienceCount`). Route is registered **before** `/files/:filename` so the static path wins. | Frontend also consumes existing endpoints **outside this module**: - `GET /backoffice/segmentation-groups/lite` — list groups for the segmentation modal selector - `GET /backoffice/segmentation-groups/:id/items/lite` — list items per group - Existing user search endpoint for assigning specific userIds --- ## 5. DI & Configuration ### Port → Adapter | Port | Adapter | Backing | |------|---------|---------| | `MeeseeksClientPort` | `MeeseeksClientAdapter` | axios + Meeseeks gateway HTTP | | `UsersByIdsPort` | `UsersByIdsAdapter` | Calls `UsersService.findByPks(ids, { instanceId })` in the `users` module to enrich `AgentFileRow.uploadedBy` from `number` to `{ id, fullName }`. | | `AudienceCountPort` | `AudienceCountAdapter` | Calls `AudiencesService.getUsersAudienceCount(instanceId, payload)` from the `audiences` module. Used by `GET /files/:filename/audience-count` to count users reached by a doc's segmentation. | Registered in `aiAgentPortsDI.ts` and wired in `src/api/diHandlers/serverHandlers.ts`. ### Constants (`business/constants.ts`) - `SAMMY_AGENT_NAME = 'sammy'` — the agent name we look up. - `AGENT_RESOLVER_CACHE_TTL_MS` — 5 min. - `MEESEEKS_REQUEST_TIMEOUT_MS` — 30 s. - `MEESEEKS_UPLOAD_TIMEOUT_MS` — 120 s (uploads can take longer). - `FOLDER_TREE_CACHE_TTL_MS` — 60 s (in-memory cache per `(instanceId, agentId)` for `GET /folders/tree`). - `FOLDER_TREE_LIST_PAGE_LIMIT` — 500, max page size Meeseeks accepts for `GET /files`. Used by the BFS expansion to fetch each level in one shot. ### Env var `MEESEEKS_BASE_URL` (in `ENV_OPTIONAL_STRING_KEYS_MAPPING` from `@humand-packages/common`) — base URL of the Meeseeks gateway. **Must include the `/api/v1/meeseeks` prefix** that the gateway mounts under. Local default in `.env.example`: `http://host.docker.internal:3000/api/v1/meeseeks`. Deployed dev: `https://api.dev.humand.co/api/v1/meeseeks`. Per-env values come from infra (SSM param + ref in `infrastructure/modules/rest_server/ssm.tf`). ### Feature Flag `CHATS_V2_SAMMY_ASSISTANT` is a **feature flag** (FF), defined in `FeatureFlagCode` enum (`humand-packages/monolith/src/api/modules/featureFlags/business/enums/featureFlagCode.ts`). FFs are per-instance booleans stored in the `FeatureFlags` table. Default value: `false`. - **Activation per community:** flip the FF `CHATS_V2_SAMMY_ASSISTANT` to `true` for the target `instanceId` (via SQL, backoffice UI, or migration). CDC publishes the change to Kafka and Cerberus + Janus consume the event automatically — Cerberus's `FEATURE_DEPENDENT` gating on the `MANAGE_AI_AGENTS` permission then auto-assigns it to admin roles in that community. - **Where the value is read:** the frontend reads it from `GET /users/me`'s feature flags array — that's how the floating AI button decides Sammy 1.0 vs 2.0 routing, and how the admin webapp decides whether to show the "AI Agent" tab. - **Server-side gate (BFF):** every endpoint in this module is gated by `MANAGE_AI_AGENTS` via `PermissionsService.validateManageAiAgents`. Communities without the FF active won't have the capability assigned, so the gate rejects with 403 before reaching Meeseeks. - **Belt-and-suspenders:** if a community somehow has `MANAGE_AI_AGENTS` granted but no agent registered in Meeseeks, the resolver throws `NotFoundError` (404) — second line of defense. --- ## 6. Testing **Unit tests** (`test/modules/aiAgent/`): - `agentResolverService.test.ts` — 4 tests covering happy path, cache hit, not-found, invalidate. The test instantiates the service directly with a mock port — no TypeDI Container setup needed. **No integration tests yet.** Pure proxies have low value for integration tests vs cost (mocking Meeseeks). Add them once the contract stabilises and Meeseeks has auth, then mock the `MeeseeksClientPort` via `getServerAdapter(MeeseeksClientPort.name)`. **No tests for:** controller (thin pass-through, integration test would catch issues), adapter (HTTP plumbing — would require nock or similar; defer until needed). --- ## 7. Open Items / Deferred Decisions These are intentionally not implemented and need to be addressed in follow-up PRs: 1. **`humandApiKey` scope list.** Each Sammy instance carries a Humand API key (set by the Meeseeks team manually; `secretsConfigured: true` in `AgentConfig` reflects its presence). Need final list from Rula of which Humand public-API endpoints Meeseeks calls with that key, so the API key is created with minimum permissions. 2. **MCP server for agent actions.** Out of scope for Sammy 2.0 (Q&A only). When tools (e.g. "request vacations via chat") are needed, register a per-agent MCP server via `tools.agentMcpServers` in `PUT /agents/:instanceId/:agentId`, pointing to a new MCP server that lives **in this monolith** (preferred) or in Meeseeks. Re-open the design discussion at that point. 3. **Provisioning automation.** Today: Meeseeks team manually creates the bot user + API key in Humand and registers the agent in Meeseeks. If/when this becomes friction, expose `POST /bo/ai-agent/provision` here that creates the bot user + API key and returns them for Rula to plug into Meeseeks. 4. **Per-endpoint integration smoke tests.** Only the permission gate is covered today (`test-integration/api/aiAgent/permissionGate.test.ts`). Add per-endpoint smoke tests against a mocked `MeeseeksClientPort` once the contract is stable. ### Resolved (permission wiring, 2026-06) - **`MANAGE_AI_AGENTS` permission wired end-to-end.** The 3-step chain from the Roles & Permissions integration guide is complete: (a) Cerberus PR #271 registered `MANAGE_AI_AGENTS` with `Availability: FEATURE_DEPENDENT` on `CHATS_V2_SAMMY_ASSISTANT`, (b) Janus PR #148 added it to `CERBERUS_MANAGED_CAPABILITIES`, (c) this main-api PR adds the capability to `PermissionCapabilityNames` + `onlyAdminCapabilities`, maps it to `GrpcGeneralPermissionCode.MANAGE_AI_AGENTS`, bumps `@humand/cerberus-grpc-ts-client` to `0.20.0`, adds a Sequelize register-only migration, creates a new `AdminModuleNames.AI_AGENTS` entry in `privateModulePermissionMap`, exposes `PermissionsService.validateManageAiAgents`, and wires the check into every `AiAgentBackofficeController` handler. Permission gate is covered by `test-integration/api/aiAgent/permissionGate.test.ts`. ### Resolved (front follow-ups, 2026-06) - **`uploadedBy` enrichment.** `AgentFileRow.uploadedBy` is enriched from `number | null` (raw Meeseeks shape) to `{ id, fullName } | null` (BFF shape exposed to the front). The service resolves the IDs in batch through `UsersByIdsPort` → `UsersService.findByPks(ids, { instanceId })`. Deleted or cross-instance IDs that don't resolve come back as `null`. Raw types (`AgentFileRow`, `AgentFilesResponse`) remain Meeseeks-shaped for internal use; the enriched siblings (`EnrichedAgentFileRow`, `EnrichedAgentFilesResponse`) are what `listFiles` / `getFile` return. - **`GET /folders/tree` endpoint.** Returns a flat `{ folders: Array<{ path, parentPath }> }` covering every folder under the agent root. Implemented in `FolderTreeService` as BFS over `MeeseeksClientPort.listFiles` (one round-trip per level, batched with `Promise.all`). Cached in memory per `(instanceId, agentId)` for `FOLDER_TREE_CACHE_TTL_MS` (60 s) — folders change infrequently and the modal that consumes this re-opens often. The service exposes `invalidate(instanceId, agentId)` for future hook-ups if Meeseeks ever signals folder mutations. - **`GET /files/:filename/audience-count` endpoint.** Returns `{ count: number, isAll: boolean }` — how many users effectively see the doc given its segmentation. The service fetches the file row from Meeseeks, composes a `SegmentationPayload` from `segmentationTags` + `segmentationUserIds` (mapped to `ALL` / `USERS` / `ITEMS` / `USERS_OR_ITEMS` config types), and delegates to `AudienceCountPort` (wraps `AudiencesService.getUsersAudienceCount`). Lazy by design — not embedded in the listing to avoid N+1 expansions. Route registered before `/files/:filename` so Express matches the static `audience-count` suffix first. - **Atomic upload + segmentation on `PUT /files/:filename`.** Same endpoint handles both **raw bytes** (legacy, single-call upload via streaming) and **multipart/form-data** (new, atomic). In multipart mode the request carries two parts: `file` (binary) and optional `segmentation` (JSON string `{ tags?, userIds? }`). The BFF uploads the file to Meeseeks, then — if the segmentation is non-empty — applies it via the segmentations endpoint. **Cleanup-on-fail:** if the segmentation call fails after a successful upload, the BFF tries to `DELETE` the file in Meeseeks and rethrows the original error; if cleanup also fails, it logs an orphan-file warning with `instanceId` + `filename` and still rethrows. The front (Tasso) chose this design: a single `FormData()` request rolls upload + audience into one atomic call. The legacy raw-bytes path stays alive untouched so partial integrations keep working. Response shape gains `segmentationApplied: boolean | null` (`null` = no segmentation requested, `true` = applied OK, `false` = empty segmentation field). **The segmentations call sends the full filename (with extension), matching the upload's `:filename` path param** — Meeseeks's upload route stores the doc under that exact key (`agents.ts:815`), while the segmentations route looks the doc up by the raw `documents[]` value with no normalization (`agents.ts:1352`). Even though `meeseeks/docs/agent-tab-gateway-api.md` describes `documents` as "base names (no extension)", the actual gateway code matches by literal string — stripping the extension makes Meeseeks create a second empty doc with the basename and apply the segmentation there, leaving the real upload unsegmented. Reported upstream; once Meeseeks aligns the doc with the code (or vice-versa), revisit this line. ### Resolved (contract sync with Meeseeks deployed dev, 2026-06) - **Bearer JWT forwarding.** All endpoints require `Authorization: Bearer ` against the Meeseeks gateway. The BFF forwards the admin's incoming token verbatim (Janus M2M, Janus backoffice user, or legacy Humand ACCESS with `scope` ⊇ `BO` — Meeseeks accepts all three). - **Field renames.** `AgentFileRow` and `AgentFolderRow` now expose `segmentationTags` / `segmentationUserIds` (was `tags` / `userIds` in an earlier draft of the Meeseeks doc). - **`AgentFileRow.downloadUrl`.** Pre-signed S3 GET URL (5-day expiry, Valkey-cached 24h). Embeds `Content-Disposition: attachment; filename=""`. Use directly from the front as `` / `` / `