# @sentinel-ai-solutions/agent-kit

Shared agent OS infrastructure for Sentinel AI Solutions' product family. Current release: **v0.21.0** (see `CHANGELOG.md`).

## What it is

agent-kit is the platform layer behind **Sentinel AI** — a multi-agent OS where
named specialists (Mira for compliance, Owen for operations, Olive for inbox,
Cassidy / Pippa for healthcare, Atticus / Lex for accountancy, Lynnea for
financial advisory) share infrastructure but maintain strict Personal/Business
credential separation. The kit ships **ten composable primitives**. The three
most relevant for integration builders are **Connections** (vendor-agnostic OAuth
orchestration with token encryption at rest), **Proactive Surfacing**
(TriggerSource → Scorer → SurfacingChannel pipeline with a Dashboard channel
for portal delivery), and **MCP** (Model Context Protocol client + tool bridge
for connecting to any MCP-native service as a first-class integration substrate).

Each consuming agent ships its own database, its own personality, its own UI.
What they share is the agent OS — the patterns that make a Claude-driven agent
loop reliable in production. Today agent-kit is consumed in production by
**Command Centre** (BW Advisory's consulting back-office) and **FrankiAI**
(family wealth portal). The credential-scope rule that keeps those two worlds
separate is documented in [`SECURITY.md`](./SECURITY.md) and enforced at
registration time by the tool dispatcher.

## Run locally

```bash
# 1. Clone
git clone https://github.com/bw-advisory/sentinel-ai-agent-kit.git
cd sentinel-ai-agent-kit

# 2. Install
npm install

# 3. Type-check
npm run typecheck

# 4. Test
npm test

# 5. Build (outputs to dist/)
npm run build

# 6. Try the portal quickstart (end-to-end pipeline demo)
cd examples/portal-quickstart
npm install
npm start
```

### Environment variables

| Variable | Required for | Description |
|----------|--------------|-------------|
| `ANTHROPIC_API_KEY` | `LLMAugmentedRelevanceScorer`, any LLM call | Your Anthropic API key |
| `CONNECTION_ENCRYPTION_KEY` | `PrismaConnectionStore` | 32-byte secret for AES-256-GCM token encryption at rest |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | `GoogleProvider` | OAuth 2.0 credentials from Google Cloud Console |
| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | `MicrosoftGraphProvider` | App registration credentials from Azure portal |
| `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` | `SlackProvider` | OAuth credentials from your Slack app config |
| `HUBSPOT_CLIENT_ID` / `HUBSPOT_CLIENT_SECRET` | `HubSpotProvider` | OAuth credentials from your HubSpot developer app |
| `XERO_CLIENT_ID` / `XERO_CLIENT_SECRET` | `XeroProvider` | OAuth credentials from your Xero developer app |
| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | `NotionProvider` | OAuth client ID and secret from your Notion public integration |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | `GitHubProvider` | OAuth app credentials from your GitHub OAuth app settings |
| `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` | `LinearProvider` | OAuth credentials from your Linear workspace application settings |
| `ATLASSIAN_CLIENT_ID` / `ATLASSIAN_CLIENT_SECRET` | `AtlassianProvider` | OAuth 2.0 (3LO) credentials from your Atlassian developer console |
| `TELEGRAM_BOT_TOKEN` | `createTelegramBot` | Token from @BotFather |
| `TELEGRAM_WEBHOOK_SECRET` | `validateTelegramWebhook` | Any random string you set as the webhook secret |

The `examples/portal-quickstart` demo only requires `ANTHROPIC_API_KEY` — all
other primitives use in-memory adapters so you don't need a database to run it.

## The ten modules

| # | Module | Subpath | Shipped | What it gives you |
|---|--------|---------|---------|-------------------|
| 1 | AgentMemory | `/memory` | v0.1 | Six-discriminator record type + typed write/read helpers |
| 2 | Telegram dispatcher | `/telegram` | v0.2 | Webhook validation, bot client, router, formatting, PendingProposal |
| 3 | Tool dispatcher | `/tools` | v0.3 | Typed `ToolDefinition`, `ToolRegistry` with scope enforcement, `runWithTools` loop |
| 4 | Photo orchestrator | `/photo` | v0.4 | Vision-extract pattern with Zod-typed output |
| 5 | Voice ingress | `/voice` | v0.5 | Voice → persist → STT → typed `Transcript` |
| 6 | Approval flow | `/approval` | v0.6 | PENDING `ProposalRecord` lifecycle with Haiku-merged edits + audit log |
| 7 | Personality framework | `/personality` | v0.7 | Composable `PersonalityDefinition`, deterministic `buildSystemPrompt`, calibration-aware |
| 8 | **Proactive Surfacing** | `/proactive-surfacing` | v0.10–v0.17 | TriggerSource / RelevanceScorer / SurfacingChannel pipeline + Dashboard channel |
| 9 | **Connections** | `/connections` | v0.11–v0.18 | Vendor-agnostic OAuth orchestration with AES-256-GCM token encryption at rest |
| 10 | **MCP** | `/mcp` | v0.19 | Model Context Protocol client + `ToolRegistryBridge` for connecting MCP-native services |

The foundational modules (1–7) are deliberately thin. They take a `PrismaClient`
from the consumer and provide the most common CRUD + retrieval patterns. Anything
specialised stays in the consumer. The three integration-layer modules
(Connections, Proactive Surfacing, MCP) are full-featured subsystems consumed by
the Sentinel AI specialists and the FrankiAI portal — they're where the bulk of
v0.10 → v0.19 work has landed.

## Mcp / Model Context Protocol layer

`@sentinel-ai-solutions/agent-kit/mcp` — the F2 integration substrate. Connect to any MCP-native service, discover its tools, and make them appear as native agent-kit tools to Claude. See [`src/mcp/README.md`](./src/mcp/README.md) for full documentation.

```ts
import { MCPClient, ToolRegistryBridge, dispatchMcpToolUse } from "@sentinel-ai-solutions/agent-kit/mcp";
import { ToolRegistry } from "@sentinel-ai-solutions/agent-kit/tools";

const registry = new ToolRegistry("business");
const bridge = new ToolRegistryBridge(registry);

// Connect to an MCP server (subprocess)
const server = new MCPClient({
  name: "my-agent",
  version: "1.0.0",
  transport: { type: "stdio", command: "npx", args: ["-y", "@my-org/mcp-server"] },
});
await server.connect();
await bridge.registerMcpServer(server); // discovers + registers tools

// Claude sees native tools + MCP tools as a single merged list
const tools = bridge.toAnthropicTools();
```

Requires `@modelcontextprotocol/sdk ^1.0.0` as a peer dependency.

## Install

This package is published to **npmjs.com** under the `@sentinel-ai-solutions` scope.

```
npm install @sentinel-ai-solutions/agent-kit
```

## Getting Started for Contributors

### Quick start

**On Windows (PowerShell):**
```powershell
git clone https://github.com/bw-advisory/sentinel-ai-agent-kit.git
cd sentinel-ai-agent-kit
npm install
npm run typecheck
npm run build
npm test
```

**On Mac / Linux (bash):**
```bash
git clone https://github.com/bw-advisory/sentinel-ai-agent-kit.git
cd sentinel-ai-agent-kit
npm install
npm run typecheck
npm run build
npm test
```

### Repository structure

`src/` contains the ten core modules: `memory/` (episodic record write + retrieval helpers), `telegram/` (webhook dispatcher and bot client), `tools/` (typed registry and multi-turn loop), `photo/` and `voice/` (vision/STT orchestrators), `approval/` (proposal lifecycle), `personality/` (agent voice framework), `connections/` (OAuth + token management), `proactive-surfacing/` (trigger → score → deliver pipeline), and `mcp/` (Model Context Protocol bridge). `tests/` holds the test suite (vitest). `examples/` contains working reference implementations (`portal-quickstart` is the end-to-end demo). `docs/` has module-specific reference docs. `dist/` is the built output.

### Build and test

```bash
# Type-check (catches TS errors before commit)
npm run typecheck

# Build (outputs to dist/)
npm run build

# Run tests
npm test

# Watch mode for development
npm run dev          # tsup --watch
npm run test:watch   # vitest in watch mode
```

All three (typecheck, build, test) must pass before opening a PR.

### Branch and commit conventions

**Branch naming:** Use `feat/`, `fix/`, `chore/`, or `refactor/` prefixes.
```bash
git checkout -b feat/add-new-provider
git checkout -b fix/token-refresh-race-condition
```

**Commit messages:** Follow conventional commits format.
```
feat: add Stripe provider to connections module

docs: update provider setup guide for new scopes

fix: prevent concurrent token refresh race condition

chore: bump agent-kit version 0.21.0 → 0.22.0
```

### First contribution checklist

- [ ] Fork or branch the repository
- [ ] Run `npm install` and verify no peer dependency warnings
- [ ] Make your changes in `src/`
- [ ] Run `npm run typecheck` — must pass with no errors
- [ ] Run `npm run build` — must produce dist/ without warnings
- [ ] Run `npm test` — all tests must pass
- [ ] Commit with a conventional-commits message
- [ ] Open a pull request with a clear description of the WHY (not just the WHAT)
- [ ] Link to relevant issue(s) or design docs if applicable
- [ ] Wait for CI to pass and review feedback

### Where to ask questions

- **Issues:** [GitHub issues](https://github.com/bw-advisory/sentinel-ai-agent-kit/issues) for bugs, feature requests, and design discussions
- **Code questions:** See the reference docs in `src/*/README.md` for each module

### Operational rules

This repository follows specific conventions for branch protection, merge strategy, and what requires approval. See [`CLAUDE.md`](./CLAUDE.md) for the complete operational ruleset — it covers shell environment expectations, model selection for AI-driven development, PR workflow, autonomous merge scope, and communication norms.

## How the Prisma schema works

`src/memory/schema.prisma` is **schema text, not a runnable file**. Consumers
copy the `AgentMemoryRecordType` enum and `AgentMemoryRecord` model into their
own `prisma/schema.prisma`. Each consumer's database holds its own AgentMemory
records — schemas are shared, data is isolated.

The TypeScript helpers (`writeEpisode`, `findRecentEpisodes`, etc.) accept a
`PrismaClient` instance from the consumer and operate against the consumer's
own database.

```ts
import { writeEpisode, findRecentEpisodes } from "@sentinel-ai-solutions/agent-kit/memory";
import { prisma } from "./db"; // consumer's Prisma client

await writeEpisode(prisma, {
  scope: "user:brad",
  payload: { kind: "telegram_message_in", text: "..." },
  source: "telegram",
});

const recent = await findRecentEpisodes(prisma, {
  scope: "user:brad",
  sinceHours: 24,
  limit: 50,
});
```

## Telegram dispatcher

`agent-kit` ships the generic Telegram framework — webhook validation, a
typed bot client, and a router. The consumer registers handlers for the
slash commands, callback actions, and message kinds it cares about. None
of the domain logic (which command does what, which model to write) lives
inside agent-kit.

```ts
import { Telegram } from "@sentinel-ai-solutions/agent-kit";

const bot = Telegram.createTelegramBot({
  token: process.env.TELEGRAM_BOT_TOKEN!,
  webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
});

const router = new Telegram.TelegramRouter(bot);

router.onSlashCommand("inbox", async (ctx) => {
  await ctx.bot.sendMessage(ctx.chatId, "Reading your inbox…", {
    parseMode: "HTML",
  });
  // ...domain-specific logic against your own services
});

router.onCallback("approve", async (ctx) => {
  // ctx.payload is the proposal id portion of `approve|<id>`
  await ctx.bot.answerCallbackQuery(ctx.callbackQueryId, { text: "Saved" });
});

router.onVoice(async (ctx) => {
  const file = await Telegram.downloadTelegramFile(ctx.bot, ctx.voice.file_id);
  // ...send the bytes to your STT provider
});

// In your webhook route handler:
export async function POST(req: Request) {
  const body = await req.text();
  const ok = Telegram.validateTelegramWebhook(
    Object.fromEntries(req.headers),
    body,
    process.env.TELEGRAM_WEBHOOK_SECRET!,
  );
  if (!ok) return new Response("Invalid signature", { status: 401 });
  await router.dispatch(JSON.parse(body));
  return new Response("OK");
}
```

For the Yes / Edit / Discard approval flow, use
`Telegram.createApprovalKeyboard(proposalId)` and persist
`Telegram.PendingProposal<T>` records via your own
`Telegram.PendingProposalStore<T>` implementation. agent-kit does not
ship a default store — your database, your model.

## Tool dispatcher

`agent-kit` ships the framework only — the typed-tool shape, the registry,
the dispatcher, and a multi-turn loop helper. Specific tools
(`log_transaction`, `send_email`, `extract_receipt`) live in the
consuming product and register themselves against an agent-kit
`ToolRegistry`. The framework never holds business logic.

### Define a tool

```ts
import { z } from "zod";
import type { Tools } from "@sentinel-ai-solutions/agent-kit";

const logTransaction: Tools.ToolDefinition<
  { amountAud: number; category: string; description: string },
  { transactionId: string }
> = {
  name: "log_transaction",
  description: "Record a business expense against the BW Advisory ledger.",
  scope: "business", // refuses to register on a personal-scope agent
  inputSchema: z.object({
    amountAud: z.number().positive(),
    category: z.string().min(1).max(120),
    description: z.string().max(2000),
  }),
  handler: async (input, ctx) => {
    const tx = await db.transaction.create({ data: input });
    ctx.log.info("logged transaction", { id: tx.id, agent: ctx.agentId });
    return { transactionId: tx.id };
  },
};
```

### Register it

```ts
import { Tools } from "@sentinel-ai-solutions/agent-kit";

const registry = new Tools.ToolRegistry("business");
registry.register(logTransaction);
// registry.register(personalScopeTool) would throw ToolScopeMismatchError
```

### Run a Claude conversation with the tools

```ts
import Anthropic from "@anthropic-ai/sdk";
import { Tools } from "@sentinel-ai-solutions/agent-kit";

const client = new Anthropic();

const result = await Tools.runWithTools({
  client,
  model: "claude-sonnet-4-6",
  system: "You are Archer, BW Advisory's business operations agent.",
  messages: [
    { role: "user", content: "Log $42.50 for Adobe Creative Cloud subscription." },
  ],
  registry,
  ctx: {
    agentId: "archer",
    agentScope: "business",
    sessionId: "sess_abc",
    log: console,
    memory: {
      toolInvocationStart: async (args) =>
        writeEpisode(prisma, {
          scope: `agent:${args.agentId}:tool`,
          payload: { phase: "start", ...args },
          source: "tool-dispatcher",
        }),
      toolInvocationSuccess: async (args) =>
        writeEpisode(prisma, {
          scope: `agent:${args.agentId}:tool`,
          payload: { phase: "success", ...args },
          source: "tool-dispatcher",
        }),
      toolInvocationError: async (args) =>
        writeEpisode(prisma, {
          scope: `agent:${args.agentId}:tool`,
          payload: { phase: "error", ...args },
          source: "tool-dispatcher",
        }),
    },
  },
});

console.log(`completed in ${result.iterations} round trips`);
```

`runWithTools` handles the multi-turn loop end-to-end — dispatching
tool calls, appending tool_result messages, looping until Claude returns
`stop_reason: "end_turn"` or the iteration cap fires.

The credential-scope check on the registry is the runtime guardrail
behind "business agents must never hold credentials for personal
financial accounts." See `SECURITY.md` for the full principle.

## Photo orchestrator

`agent-kit` ships the generic pattern for "inbound photo → persist →
hand to Claude vision → typed structured data." Channel-specific
ingestion (Telegram, email, web upload), durable storage (Cloudinary,
S3, Vercel Blob), and the extraction schemas themselves (receipt,
business card, wealth statement) all live in the consuming repos.

### Define a typed extraction schema

```ts
import { z } from "zod";

const receiptSchema = z.object({
  vendor: z.string(),
  total: z.number(),
  currency: z.string().length(3),
  date: z.string(), // ISO date
  // Optional but recommended — drives the auto-log vs ask-for-confirmation
  // decision in your downstream code.
  confidence: z.number().min(0).max(1),
});
```

### Run a photo through the orchestrator

```ts
import Anthropic from "@anthropic-ai/sdk";
import { Photo, Telegram } from "@sentinel-ai-solutions/agent-kit";

const anthropic = new Anthropic();

// PhotoSource adapter for a Telegram photo (consumer-side wrapper).
const source: Photo.PhotoSource = {
  channel: "telegram",
  externalId: photo.file_id,
  mimeType: "image/jpeg",
  fetchBytes: () => Telegram.downloadTelegramFile(bot, photo.file_id),
  caption: msg.caption,
};

// PhotoStorage — real adapters (Cloudinary, S3) live in your repo;
// the bundled MemoryPhotoStorage is for tests + local dev.
const storage = new Photo.MemoryPhotoStorage();

const result = await Photo.processPhoto({
  source,
  storage,
  vision: { client: anthropic, model: "claude-sonnet-4-6" },
  schema: receiptSchema,
  systemPrompt: "Extract structured receipt data from photos.",
  userPrompt: "Extract vendor, total, currency, date.",
  ctx: { agentId: "archer", userId: "u_brad" },
});

if (result.data && result.confidence > 0.8) {
  await logTransaction(result.data, { photoUrl: result.photoUrl });
} else {
  await askUserToConfirm(result); // partial.reason explains why
}
```

`processPhoto` fetches the bytes exactly once, then runs `storage.persist`
and the vision call in parallel — wall-clock time is bounded by the
slower operation rather than their sum.

The `confidence` field on the schema is how consumers decide
"auto-log vs ask-for-confirmation" downstream. Schemas without a
`confidence` field default to `1` on success and `0` on failure.

## Voice ingress

`agent-kit` ships the generic pattern for "inbound voice memo → persist
→ run STT → typed `Transcript`." Channel-specific ingestion (Telegram
voice, browser upload, future call-recording), durable storage, and the
STT provider itself all live in the consuming repos. The framework
keeps the kit free of heavy SDK dependencies — same approach Photo
takes with the Anthropic vision client.

### Implement an STT provider in your consumer repo

```ts
import type { Voice } from "@sentinel-ai-solutions/agent-kit";
import speech from "@google-cloud/speech";

export class GoogleCloudSTTProvider implements Voice.STTProvider {
  readonly name = "google-cloud-stt";
  private readonly client = new speech.SpeechClient();

  async transcribe(args: Voice.STTTranscribeArgs): Promise<Voice.STTResult> {
    const [response] = await this.client.recognize({
      audio: { content: args.audioBytes.toString("base64") },
      config: {
        encoding: "OGG_OPUS", // map from args.mimeType
        sampleRateHertz: 48000,
        languageCode: args.language ?? "en-AU",
        enableSpeakerDiarization: args.enableSpeakerDiarization ?? false,
        diarizationSpeakerCount: args.expectedSpeakers,
        enableWordTimeOffsets: true,
      },
    });
    // ...flatten Google's response into the Transcript shape
    return { text, segments, duration, language, confidence };
  }
}
```

### Run a voice memo through the orchestrator

```ts
import { Telegram, Voice } from "@sentinel-ai-solutions/agent-kit";

const source: Voice.VoiceSource = {
  channel: "telegram-voice",
  externalId: voice.file_id,
  mimeType: "audio/ogg",
  fetchBytes: () => Telegram.downloadTelegramFile(bot, voice.file_id),
  durationSec: voice.duration,
};

const storage = new Voice.MemoryVoiceStorage(); // swap for Vercel Blob / GCS in prod
const stt = new GoogleCloudSTTProvider();

const transcript = await Voice.processVoice({
  source,
  storage,
  stt,
  language: "en-AU",
  ctx: { agentId: "archer", userId: "u_brad" },
});

await classifyIntent(transcript.text, { audioUrl: transcript.audioUrl });
```

`processVoice` fetches the bytes exactly once, then runs `storage.persist`
and `stt.transcribe` in parallel. Errors from either side bubble up so
the caller can wrap with a resilience layer (retry, fallback provider).

For tests and local development, use `Voice.MockSTTProvider` — it
returns scripted transcripts and records every call, so you can assert
behaviour without burning real STT quota.

## Approval flow

`agent-kit` ships the durable lifecycle for "agent proposes an action →
user reviews → executor runs the approved payload." Each consumer
supplies the storage adapter (Prisma model in Archer, equivalent in
FrankiAI), the executor (whatever runs the approved action), and a
`ProposalAuditLogger` that typically wraps `writeEpisode` from the
memory module. agent-kit holds the verbs, the shape, and the Telegram
UI helpers; nothing product-specific.

### End-to-end Telegram example

```ts
import Anthropic from "@anthropic-ai/sdk";
import {
  Approval,
  Telegram,
  writeEpisode,
} from "@sentinel-ai-solutions/agent-kit";
import { prisma } from "./db";

const anthropic = new Anthropic();

// 1. Your storage adapter conforms to Approval.ProposalStorage —
//    typically a thin wrapper over a Prisma model. For tests and
//    local dev use Approval.MemoryProposalStorage.
const storage: Approval.ProposalStorage<EmailPayload> = makePrismaStorage(prisma);

// 2. Wire the audit logger to your AgentMemory write path.
const audit: Approval.ProposalAuditLogger = {
  record: (event) =>
    writeEpisode(prisma, {
      scope: `agent:${event.agentId}:approval`,
      payload: event,
      source: "approval-flow",
    }),
};

// 3. When the agent decides to propose, persist the proposal and post
//    the Telegram approval card.
const proposal = await Approval.createProposal({
  storage,
  audit,
  ctx,
  record: {
    agentId: "archer",
    userId: "u_brad",
    type: "send_email",
    source: "AGENT_INITIATED",
    title: "Send invoice reminder",
    rationale: "Invoice #42 is 7 days overdue.",
    payload: { to: "client@example.com", subject: "Friendly reminder", body: "…" },
  },
});

const { text, inlineKeyboard } = Approval.buildApprovalMessage(proposal);
await bot.sendMessage(chatId, text, { reply_markup: inlineKeyboard });

// 4. On the receive side, route Telegram callbacks through the parser
//    and call the matching handler.
router.onCallback("approve", async (ctx) => {
  const parsed = Approval.parseApprovalCallback(ctx.callbackData);
  if (!parsed) return;
  await Approval.approveProposal({
    id: parsed.proposalId,
    storage,
    audit,
    ctx: dispatchCtx,
    executor: sendEmail,
  });
});

router.onCallback("edit", async (ctx) => {
  const parsed = Approval.parseApprovalCallback(ctx.callbackData);
  if (!parsed) return;
  // Prompt the user for their correction text, then merge it via Haiku.
  await Approval.editProposal({
    id: parsed.proposalId,
    userCorrection: userMessage,
    storage,
    audit,
    haikuClient: anthropic,
    ctx: dispatchCtx,
  });
});

router.onCallback("discard", async (ctx) => {
  const parsed = Approval.parseApprovalCallback(ctx.callbackData);
  if (!parsed) return;
  await Approval.dismissProposal({
    id: parsed.proposalId,
    storage,
    audit,
    ctx: dispatchCtx,
  });
});
```

`editProposal` runs the user's free-text correction through Haiku with
the original payload, returns the merged proposal in PENDING state, and
preserves the agent's first attempt in `originalPayload` so the UI can
diff the change. Every state transition writes a structured
`ProposalAuditEvent` to the audit log — `CREATED`, `APPROVED`, `EDITED`,
`SNOOZED`, `COMPLETED`, `DISMISSED`, `EXECUTOR_FAILED`.

If the executor throws inside `approveProposal`, the proposal stays
`PENDING` (so the user can retry), the failure surfaces in the return
value (`{ executed: false, error }`), and an `EXECUTOR_FAILED` audit
event is written.

## Personality framework

`agent-kit` ships the shape, the builder, and the channel + calibration
merge semantics for an agent's voice. Each consumer (Archer, FrankiAI,
every commercial customer) supplies its own concrete
`PersonalityDefinition` instance — agent-kit holds no voice content of
its own.

Same agent, different surfaces. The base personality is defined once;
the channel override declares what changes for Telegram vs. email vs.
dashboard vs. voice. The composed system prompt is deterministic — same
inputs always produce the same output, which keeps the builder testable
and prompt-cache-friendly across consecutive turns.

### Define a personality

```ts
import type { Personality } from "@sentinel-ai-solutions/agent-kit";

export const archerPersonality: Personality.PersonalityDefinition = {
  name: "Archer",
  role: "BW Advisory's business operations agent",
  about: "A direct, useful assistant focused on running Brad's consulting business.",
  tone: {
    register: "professional",
    energy: "measured",
    humor: "dry",
    directness: "direct",
  },
  style: {
    sentenceLength: "mixed",
    listsAllowed: true,
    emojiPolicy: "never",
    addressTheUserAs: "Brad",
    selfReference: "first-person",
  },
  preferredPhrasing: ["I'd suggest", "happy to dig in"],
  avoidPhrasing: ["as an AI", "I'm just a"],
  signaturePhrases: ["let me check that"],
  doList: [
    "answer the actual question",
    "say when you don't know",
    "show the working when it matters",
  ],
  dontList: [
    "pad replies with filler",
    "invent capabilities you don't have",
  ],
  channels: {
    telegram: {
      style: { sentenceLength: "short", emojiPolicy: "sparingly" },
    },
    email: {
      tone: { register: "formal" },
      style: { sentenceLength: "medium", listsAllowed: false },
    },
    voice: {
      style: {
        sentenceLength: "short",
        listsAllowed: false,
        emojiPolicy: "never",
      },
    },
  },
  version: "1.0.0",
  updatedAt: new Date("2026-05-22T00:00:00Z"),
};
```

### Compose a system prompt

```ts
import { Personality, findRecentEpisodes } from "@sentinel-ai-solutions/agent-kit";
import { archerPersonality } from "./personality";

// Pull the most recent CALIBRATION records — these are the user's
// in-flight tone corrections, applied at composition time only.
const recentCalibrations = await findRecentCalibrations(prisma, {
  scope: "user:brad",
  sinceHours: 24 * 7,
});

const systemPrompt = Personality.buildSystemPrompt({
  personality: archerPersonality,
  channel: "telegram",
  task: "Reply to the user's message about last week's expenses.",
  context: liveContext,
  recentCalibrations,
  includeCapabilitiesHint: true,
});

// Send to Claude as the system prompt, deterministic across turns
// when only the user message changes — friendly to prompt caching.
const response = await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  system: systemPrompt,
  messages: [{ role: "user", content: userMessage }],
});
```

### Tone calibration

The personality stored on disk doesn't change when the user says
"too formal" or "shorter please." Those corrections are captured as
`AgentMemory` `CALIBRATION` records and **layered at composition time**.
`buildSystemPrompt` calls `applyCalibrationsToPersonality` under the
hood — calibrations are processed chronologically, later corrections
override earlier ones on the same dimension, and movement is clamped at
the ends of each scale.

```ts
// Recorded somewhere upstream when the user pushed back on the last reply.
await writeCalibration(prisma, {
  scope: "user:brad",
  payload: { dimension: "verbosity", direction: "less" },
  source: "user-pushback",
});

// On the next turn, buildSystemPrompt picks up the calibration and
// renders the personality with shorter sentences AND surfaces the
// nudge to Claude as "less verbosity" so it can reason about why.
```

### Per-customer personalities

For the commercial product, **each customer's agent gets its own
`PersonalityDefinition` during onboarding**. The collection UI (a form
or wizard) is a future build; `agent-kit` provides the shape, the zod
validator (`personalityDefinitionSchema`), and the composition path
today. The customer's persisted personality row, the channel overrides,
and the calibration stream all flow through the same builder — no
divergence between surfaces, no drift between releases.

## Connections module

Vendor-agnostic OAuth orchestration. You define a `Provider` once and the
framework handles authorization URL construction, code exchange, token
storage, transparent refresh (within a 5-minute expiry window), and
revocation. The same primitive serves SMB onboarding (Sentinel AI customer
integrations) and consumer Life Admin product onboarding — Connections are
the biggest barrier to high-volume adoption, and this module removes the
bespoke OAuth plumbing on every new vendor.

Five core abstractions: `ProviderName` (open string union for well-known
vendor IDs), `Provider` (the OAuth contract — `buildAuthorizationUrl`,
`exchangeCode`, `refreshAccessToken`, optional `revokeAccess` and
`fetchUserInfo`), `Connection` (the persisted operator↔provider link with
tokens, scopes, expiry), `ConnectionStore` (persistence adapter — the
in-memory store is for tests; `PrismaConnectionStore` is for production),
and `ConnectionManager` (the high-level six-method API: `connect`,
`completeConnection`, `getAccessToken`, `getConnectionStatus`, `disconnect`,
`listConnections`).

**Shipped providers (9 total):** `GoogleProvider` (v0.11),
`MicrosoftGraphProvider` and `SlackProvider` (v0.15), `HubSpotProvider`,
`XeroProvider`, and `NotionProvider` (v0.18), `GitHubProvider`,
`LinearProvider`, and `AtlassianProvider` (v0.20). Each provider ships with
named scope-constant bundles and pre-composed groupings so consumers never
memorise vendor-specific strings. **`PrismaConnectionStore`** (v0.13)
encrypts `accessToken` and `refreshToken` at rest with **AES-256-GCM**
(256-bit key derived via scrypt from `CONNECTION_ENCRYPTION_KEY`, 96-bit
random IV per write, 128-bit auth tag). **Queued for future batches:**
per-seat enforcement, mirror enforcement, key rotation tooling. **Queued for
a separate batch:** MCP integration as a Provider implementation.

```ts
import {
  ConnectionManager,
  GoogleProvider,
  GoogleScopes,
  MemoryConnectionStore,
} from "@sentinel-ai-solutions/agent-kit/connections";

const manager = new ConnectionManager({
  store: new MemoryConnectionStore(), // swap for PrismaConnectionStore in prod
  providers: new Map([["google", new GoogleProvider()]]),
  credentials: new Map([
    ["google", {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

// Step 1 — redirect user to Google
const { authorizationUrl, state } = await manager.connect("operator_123", "google", {
  scopes: [GoogleScopes.GMAIL_SEND, GoogleScopes.CALENDAR_EVENTS],
});

// Step 2 — handle callback (state already verified by your route handler)
await manager.completeConnection("operator_123", "google", req.query.code);

// Step 3 — use it. Refresh happens transparently within 5 min of expiry.
const accessToken = await manager.getAccessToken("operator_123", "google");
```

See [`src/connections/README.md`](./src/connections/README.md) for the full
module reference — provider-specific setup (Azure AD app registration,
Slack workspace install, Google `prompt=consent` requirement), scope-bundle
tables, `PrismaConnectionStore` production wiring, encryption details, the
"add a new provider" guide, and the common-gotchas catalogue.

## Proactive Surfacing module

Generic infrastructure for "agent notices something the operator should
look at, scores it, and delivers it on the right channel." Build a new
`TriggerSource` once; scoring, threshold filtering, channel delivery,
debouncing, rate-limiting, and calibration are free.

Four core abstractions: `TriggerSource` (emits `CandidateEvent` objects
from a data domain — intel scan, pipeline stall, AR overdue, school
calendar), `RelevanceScorer` (scores each candidate against the current
`OperatorContext` and returns score + urgency + rationale),
`SurfacingChannel` (formats a scored candidate into a channel-specific
message and delivers it), and `CalibrationLearner` (records operator
responses — `acted` / `dismissed` / `more_like_this` / `less_like_this` —
and adjusts per-source multipliers).

**Shipped scorers:** `RuleBasedRelevanceScorer` (v0.10, fast deterministic)
and `LLMAugmentedRelevanceScorer` (v0.12, wraps the rule scorer with a
Sonnet sanity-check for candidates in the uncertain middle band — clearly
irrelevant `< 0.4` and clearly relevant `> 0.75` candidates skip the LLM
entirely; results cached for 60 s per `(operatorId, sourceName, candidate.id)`).
**Shipped channels:** `TelegramSurfacingChannel` (v0.10, HTML + inline CTA
keyboard) and `DashboardSurfacingChannel` (v0.14, enqueues to a
`SurfacingMessageQueue` for a portal inbox). **Shipped rate-limit primitives:**
`Debouncer` + `InMemoryDebounceStore` (v0.12) plus the standalone
`RateLimiter` and cross-process `PrismaDebounceStore` (v0.17) — the Prisma
store is required for multi-process deployments where web + worker + cron
need to share quota state. **Shipped operator-config:**
`InMemoryOperatorPreferencesStore` (v0.14) for per-operator threshold,
channel, and per-source calibration overrides.

```ts
import {
  DashboardSurfacingChannel,
  InMemoryOperatorPreferencesStore,
  InMemorySurfacingMessageQueue,
  LLMAugmentedRelevanceScorer,
  MemoryCalibrationLearner,
  ProactiveSurfacingPipeline,
  RuleBasedRelevanceScorer,
} from "@sentinel-ai-solutions/agent-kit/proactive-surfacing";
import Anthropic from "@anthropic-ai/sdk";

const calibration = new MemoryCalibrationLearner();
const scorer = new LLMAugmentedRelevanceScorer(
  new RuleBasedRelevanceScorer(calibration),
  new Anthropic(),
);

const queue = new InMemorySurfacingMessageQueue();
const dashboardChannel = new DashboardSurfacingChannel(queue);

const pipeline = new ProactiveSurfacingPipeline(
  [/* your TriggerSource[] */],
  scorer,
  new Map([["dashboard", dashboardChannel]]),
  calibration,
  {
    scoreThreshold: 0.6,
    operatorPreferencesStore: new InMemoryOperatorPreferencesStore(),
  },
);

await pipeline.run(
  { operatorId: "brad", preferredChannels: ["dashboard"] },
  new Date(Date.now() - 24 * 60 * 60 * 1000),
);

// Portal renders the inbox from the queue
const messages = await queue.list("brad", 20);
```

See [`src/proactive-surfacing/README.md`](./src/proactive-surfacing/README.md)
for the full module reference — the pipeline flow diagram, `CandidateEvent`
payload conventions, Telegram + dashboard callback formats (`ps_<action>|<shortId>`),
`PipelineCoachingTriggerSource` example for CRM-backed sources, Prisma
schemas for `SurfacingMessage` / `OperatorSurfacingPreferences` /
`SurfacingEmissionLog`, and the deferred-feature roster.

## Releases

Bump the `version` field in `package.json` as part of any PR. On merge to
`main`, GitHub Actions runs the type check, tests, and build, then publishes
the new version to GitHub Packages and pushes the matching `vX.Y.Z` git tag
automatically. Manual `npm version` / `git push --follow-tags` invocations
are no longer needed.

If `package.json` is not bumped, the workflow still runs the gate checks
but skips the publish step — the registry already has that version.

## License

UNLICENSED — private package, all rights reserved by Sentinel AI Solutions Pty Ltd.

## Status

**v0.21.0** — the ten-module kit, in production at Command Centre and
FrankiAI. Foundational primitives (AgentMemory, Telegram dispatcher, Tool
dispatcher, Photo orchestrator, Voice ingress, Approval flow, Personality
framework) are stable. **Connections** (v0.20 completes F1 Batch 4 with
GitHub / Linear / Atlassian providers — 9 providers total; per-seat and
mirror enforcement queued next). **Proactive Surfacing** (additional trigger
sources, persistent aggregator queue, email channel, WebSocket push for
dashboards queued). **MCP** (v0.19 ships the F2 integration layer —
`MCPClient` + `ToolRegistryBridge`; serving agent-kit capabilities as an
MCP server is queued for a future batch). See `ROADMAP.md` for the full
sequence.