The chatbot lives at cartridges/global/chatbot/ and renders inside an iframe on the Console. A user signs in once via
Console; the parent window injects the access token via postMessage; the cartridge calls its backend with the
token in X-Auth-Token; the backend verifies the JWT, calls Bedrock with the platform guardrail attached, and returns
the model's response.
| File | Role |
|---|---|
versions.tf | Pins AWS + random providers (need random_password for the origin secret). |
variables.tf | Module inputs: project name, KMS keys, Cognito pool, Bedrock model ARNs, Console origin, guardrail config (id + version + KMS). |
auth.tf | Cognito user-pool client on the shared pool. USER_SRP_AUTH + REFRESH_TOKEN_AUTH only. |
backend.tf | Lambda function + IAM role + IAM policy + API Gateway HTTP API + integration + routes + stage + random_password.origin_secret + aws_lambda_permission for API GW. |
frontend.tf | S3 bucket + CloudFront distribution with two origins (S3 frontend, API GW backend), response_headers_policy with CSP frame-ancestors for Console embedding, SPA-router CF Function on default behavior. |
lambda/handler.js | Zero-npm ESM. Imports @aws-sdk/client-bedrock-runtime + client-cognito-identity-provider from the runtime. Hand-rolled JWT verification via crypto.subtle. Shared-secret origin check, Bedrock InvokeModelWithResponseStream accumulated server-side. |
lambda/package.json | Minimal: {"type":"module"}. No dependencies. Required only so Node sees the file as ESM. |
frontend/index.html, app.js, styles.css, favicon.svg | Chat UI: model picker (Haiku/Sonnet 4.5), message bubbles, blinking cursor on send. Loads SDK assets from the Console origin via /config.json. |
cloudfront/spa-router.js | Minimal SPA router CF Function. Rewrites client-side routes to /index.html, passes /sdk/ and /api/ through unchanged. |
This is the doctrinal value of this cartridge — more important than the code. Each is now reflected in the corresponding reference doc (Step.4.5.b for layers, Step.6.h for Console reference). Repeated here as a single sweep so vibe app 3 doesn't have to chase footnotes.
CloudFront Origin Access Control + Lambda Function URL has a body-hash mismatch on every POST/PUT/PATCH:
OAC's canonical-request body hash and Function URL's receive-side validation hash differ. SigV4 fails with
"signature does not match" on every non-GET. Confirmed unfixable from our side; the CloudFront Function workaround
that pins x-amz-content-sha256: UNSIGNED-PAYLOAD is overridden by OAC.
Replacement pattern: CloudFront's /api/* behavior points at an API Gateway HTTP API.
Layer 2 enforcement is an X-Origin-Secret shared header injected via the CloudFront origin's custom_header
block, generated by a Terraform random_password and verified in the Lambda handler. The API Gateway endpoint is
public but unusable without the header — verified: direct hits return 403. The three-layer doctrine (org SCP, transport
auth, application JWT) is preserved; only Layer 2's mechanism splits into "OAC + Function URL for GET-only" and "API GW
HTTP API + shared secret for POST-capable."
Bedrock's GetFoundationModel reports inferenceTypesSupported: [INFERENCE_PROFILE] for Claude 4.5 Haiku
and Sonnet — they have no on-demand throughput. Invoking the bare foundation-model ID returns:
"Invocation of model ID anthropic.claude-haiku-4-5-... with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model."
The handler must call us.anthropic.claude-haiku-4-5-20251001-v1:0 (and the Sonnet equivalent), not the bare
ID. The IAM policy needs BOTH the inference-profile ARN AND the underlying foundation-model ARN in every region the
profile may route to (us-east-1, us-east-2, us-west-2).
On our account aws bedrock list-inference-profiles does not return the Claude 4.5 us.* profiles even
though direct invocation works. Reasoning from the list will lead to "the profile doesn't exist; we must use the
foundation-model ID" — and you'll hit doctrine #2's error. Treat the list as advisory only. The authoritative test
is a direct invoke against the suspected profile ID.
The platform default guardrail (ai-sandbox-default) is encrypted with the agent CMK. When a caller invokes a
model with that guardrail attached, Bedrock decrypts the guardrail policy server-side using the CALLER's credentials, not
its own. Missing kms:Decrypt on the guardrail CMK yields:
"You do not have access to the guardrail's KMS key. Please ensure that kms:Decrypt permissions are correctly configured."
Fix shipped: the guardrails module exposes default_guardrail_kms_arn; callers (chatbot, vibe app 3, the obs Lambda's
future narrative-on-Sonnet path) thread it in and add a KMSDecryptGuardrailKey statement to their role policy. The
CMK's key policy delegates kms:* to root, so IAM-side grants are sufficient — no key-policy edit required.
AWS Cost Explorer's per-tag aggregation is gated by an account-level activation that linked accounts (this is one) can't perform from Terraform — only the payer-account admin can activate, and Cost Explorer takes ~24h to backfill afterward. That's a multi-day round trip before per-app cost shows up. Unacceptable for an operator surface that's supposed to show "what's spending what" today.
The pattern that actually works: every Bedrock-invoking Lambda emits a structured completion log line right after
InvokeModel returns (or after the stream's message_stop event). Stable schema across cartridges:
{
"level": "info",
"msg": "<cartridge>-complete", // chat-complete, narrative-complete, etc.
"user": "<username or _scheduled-job>",
"model": "<modelId>",
"inputTokens": <int>,
"outputTokens": <int>,
"durationMs": <int>
}
The obs Lambda's /api/admin/cost/estimate endpoint runs one CloudWatch Logs Insights query per cartridge log
group, sums tokens by model × user, multiplies by the per-model Bedrock pricing table maintained in code
(BEDROCK_PRICING_USD_PER_M_TOKENS), and returns the breakdown. The Cartridges tab's "Calculate Now" button
shows it: account total, by-model table, by-cartridge table, collapsible pricing reference.
Operator extension path: new model → add a row to the pricing map. New cartridge → add its log group to
COST_LOG_GROUPS + emit the *-complete log line with the schema above. UI picks both up
automatically. Cost Explorer's tag-based view stays as the eventual reconciliation source of truth, but the platform
doesn't depend on it for day-to-day visibility.
Per-cartridge cost is now visible in the obs app's Cartridges tab (Step.6.j is the build doc; that tab is the operating surface). The cartridge's components and roughly what each contributes per month at internal-only usage:
| Component | Monthly cost (USD) | Notes |
|---|---|---|
| Bedrock invocations | $variable | Pay-per-token; Haiku is the cheap default, Sonnet is the per-call escalation. Token cost is the dominant driver at any non-trivial use. |
| API Gateway HTTP API | ~$0.01 | $1 per million requests. Internal use is well below 1M/month. |
| Lambda | ~$0.05 | 1GB / 120s timeout; long streams cost more per call but volume is tiny. |
| CloudFront + S3 | ~$0.05 | Same as Console; static UI + tiny origin transfer. |
| KMS data keys | ~$0.10 | 4 CMKs touched (frontend, backend, guardrail, audit). $1/CMK/month is divided across resources. |
| CloudWatch logs | ~$0.05 | Per-cartridge log group; small chat history retains cheaply. |
| Fixed total (no model use) | ~$0.30/month | Bedrock dominates the actual bill in active use. |
The cartridge has its own monthly budget configurable in the obs app's Cartridges tab; the operator sets monthlyUsd
and a warning threshold. Hard enforcement (block invocations) is deliberately not wired today — visibility-only — to keep
the cost layer from accidentally killing a pilot.
The chatbot ships in roughly 1500 lines of Terraform plus 250 lines of Lambda handler. Most of what makes it secure, observable, and recoverable was already in the substrate. The cartridge inherited:
| From | What the cartridge gets for free |
|---|---|
| Bootstrap (Step 1) | OIDC provider for GitHub Actions, permission boundary attached to every role. |
| Identity (Step 2) | Data-governance CMKs by tier × org. Chatbot picks ceo-office-low-risk for the frontend bucket and eng-low-risk for the backend logs / env vars. No new KMS keys created. |
| Network (Step 3) | VPC, NFW alert-mode, DNS Firewall, single egress point. Chatbot's Lambda is not in-VPC today; when it moves in-VPC (a future hardening), the substrate accepts it without changes. |
| Observability (Step 4) | CloudWatch log groups KMS-encrypted by tier. The narrative generation that powers the obs app's Executive tab already sees chatbot usage as Bedrock invocations without needing a per-cartridge wiring. |
| Obs vibe app (Step 4.5) | Shared Cognito user pool. The chatbot creates ONLY an app client on the existing pool — no new user pool, no new MFA setup, no new email-invite flow. |
| Guardrails (Step 6.f-area) | Platform-default Bedrock guardrail applied on every invoke. No per-cartridge guardrail tuning needed for the pilot. |
| Console (Step 6.g/h) | SDK v1 at the Console origin: tokens.css, cartridge-auth.js, and now table.js/pagination.js/modal.js/components.css. Cartridge does PCSDK.auth.ready() and gets the JWT — no auth code in the cartridge. |
The cartridge added zero new substrate. It used the platform's KMS keys, the platform's Cognito pool, the platform's
guardrail, the platform's SDK, and the platform's observability. The only NEW security primitive — the
X-Origin-Secret pattern — is itself a substrate-level improvement (once we add it to a shared module, every
future cartridge gets it for free). This is what the iOS / app-OS analogy means in practice: the cartridge is thin
because the platform is thick.
backend.tf. Copy the
random_password resource, the four API GW resources (api, integration, route(s), stage), and the
aws_lambda_permission for API GW invoke. On the frontend side, the CloudFront origin uses a
custom_header block that injects the secret. Lambda checks it at the top of the handler.
lambda/handler.js. The hand-rolled crypto.subtle
RSA-SHA256 + JWKS fetch + caller-client-id check is 60 lines. Copy verbatim; only the user pool ID + region env
vars change per cartridge.
PCSDK.ui.Table, PCSDK.ui.Pagination, PCSDK.ui.Modal) are
now available at the Console origin. Cartridges load them the same way as cartridge-auth.js — set
<script src>/<link href> from config.json.sdkBase.
App tag at the module call site. Set App = "<name>" in the tags map for the
cartridge module. Cost Explorer will group its spend automatically (after the ~24h backfill window).