PayCargo · AI-Sandbox · Step 6.j

6.jChatbot cartridge — build walkthrough

First native cartridge. Embedded in Console via shared Cognito + postMessage JWT. Streams from Bedrock through the platform guardrail. The proof point for the Forge model and the source of five substantial doctrine updates.
Companion to: Step.6.g/h (Console build & reference), Step.6.i (cartridge metadata doctrine), Step.4.5.b (three-layer defense) · Status: Live in AI-Sandbox as of 2026-06-03
Shipped
The chatbot is the second vibe app (after the obs app itself) and the first to live inside the Console as a cartridge. It validates end-to-end: SDK at the Console origin, JWT injected via postMessage, API Gateway HTTP API as the backend origin, Bedrock with platform guardrail, zero-npm Lambda. Anywhere this walkthrough diverges from Step.6.h's Console reference, the divergence is one of the five doctrine updates this cartridge surfaced — flagged below.

Contents

  1. What shipped
  2. Files
  3. The five doctrine updates this cartridge surfaced
  4. Cost
  5. What it inherited from the substrate
  6. What vibe app 3 should reuse

1. What shipped

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.

user │ https://da7cclwu7mps0.cloudfront.net (Console) ▼ ┌────────────── Console (parent window) ─────────────┐ │ /sdk/v1/css/tokens.css (S3 + CF + OAC) │ │ /sdk/v1/js/cartridge-auth.js (postMessage helper) │ │ /sdk/v1/css/components.css (Table/Pagination/Modal) │ │ │ iframe src="https://d249hkf4mwzw7s.cloudfront.net"│ └──────────────────┬─────────────────────────────────┘ │ postMessage { accessToken, ... } ▼ ┌────────────── Chatbot frontend (iframe) ───────────┐ │ Same-origin: S3 + CF + OAC │ │ PCSDK.auth.ready() → resolves token from parent │ │ fetch('/api/chat', headers: X-Auth-Token: ...) │ └──────────────────┬─────────────────────────────────┘ │ POST /api/chat (CF behavior /api/*) ▼ ┌─ CloudFront origin: API Gateway HTTP API ─┐ │ custom_header: X-Origin-Secret = ... │ └──────────────────┬────────────────────────┘ ▼ ┌────── Lambda ───────┐ │ verify X-Origin-Secret │ verify JWT (crypto.subtle) │ Bedrock InvokeModelWithResponseStream │ modelId = us.anthropic.claude-haiku-4-5-... │ guardrailIdentifier = ai-sandbox-default └─────────────────────┘

2. Files

FileRole
versions.tfPins AWS + random providers (need random_password for the origin secret).
variables.tfModule inputs: project name, KMS keys, Cognito pool, Bedrock model ARNs, Console origin, guardrail config (id + version + KMS).
auth.tfCognito user-pool client on the shared pool. USER_SRP_AUTH + REFRESH_TOKEN_AUTH only.
backend.tfLambda function + IAM role + IAM policy + API Gateway HTTP API + integration + routes + stage + random_password.origin_secret + aws_lambda_permission for API GW.
frontend.tfS3 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.jsZero-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.jsonMinimal: {"type":"module"}. No dependencies. Required only so Node sees the file as ESM.
frontend/index.html, app.js, styles.css, favicon.svgChat 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.jsMinimal SPA router CF Function. Rewrites client-side routes to /index.html, passes /sdk/ and /api/ through unchanged.

3. The five doctrine updates this cartridge surfaced

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.

Doctrine #1

OAC + POST is dead. API Gateway HTTP API + X-Origin-Secret replaces it for any cartridge backend that takes non-GET methods.

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."

Doctrine #2

Claude 4.5 invocation requires the us.* cross-region inference profile. Foundation-model IDs error.

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).

Doctrine #3

list-inference-profiles is not authoritative. Verify by direct invoke.

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.

Doctrine #4

The caller role needs kms:Decrypt on the guardrail's CMK. Bedrock decrypts the guardrail policy with caller credentials.

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.

Doctrine #5

Log token counts at the source; aggregate at the platform. Don't wait for Cost Explorer.

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.

4. Cost

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:

ComponentMonthly cost (USD)Notes
Bedrock invocations$variablePay-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.051GB / 120s timeout; long streams cost more per call but volume is tiny.
CloudFront + S3~$0.05Same as Console; static UI + tiny origin transfer.
KMS data keys~$0.104 CMKs touched (frontend, backend, guardrail, audit). $1/CMK/month is divided across resources.
CloudWatch logs~$0.05Per-cartridge log group; small chat history retains cheaply.
Fixed total (no model use)~$0.30/monthBedrock 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.

5. What it inherited from the substrate

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:

FromWhat 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 Forge model proof

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.

6. What vibe app 3 should reuse

  1. The API Gateway HTTP API + shared-secret pattern from 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.
  2. The zero-npm JWT verify in 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.
  3. The SDK UI modules (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.
  4. The 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).
  5. The Cartridges tab in the obs app already knows how to display new cartridges by tag value. Once the tag is set, the row appears within 24h with budget/access/retention fields ready to configure.