PayCargo · AI-Sandbox · Step 6.q

6.qBuild a cartridge with just the SDK

End-to-end walkthrough: empty repo to working cartridge using only the pulled Forge SDK. No clone of the main repo, no internal-only paths, no special access. Hand this to a new cartridge author and they ship by end of day.
Block: 6.q (Console doctrine; walkthrough companion to 6.o SDK versioning + 6.j chatbot walkthrough) · Audience: Cartridge author starting from scratch · Time: < 1 hour to first deploy
Walkthrough
Build a minimal "echo" cartridge end-to-end using only the SDK pulled via pull-sdk.sh. Auth, structured logging, deploy via Terraform. After this, the chatbot walkthrough (Step 6.j) shows how to add Bedrock invocation, and the OAuth doctrine (Step 6.n) shows how to call Salesforce.

Sections

  1. What you're building
  2. Prerequisites
  3. Step 0: Create the repo
  4. Step 1: Pull the SDK
  5. Step 2: Scaffold from the template
  6. Step 3: Write the Lambda handler
  7. Step 4: Write backend.tf
  8. Step 5: Write the cartridge manifest
  9. Step 6: Write the frontend (optional)
  10. Step 7: Deploy
  11. Step 8: Verify
  12. Common gotchas
  13. Pre-PR checklist
  14. Where to go from here

1What you're building

A minimal but complete cartridge. Two API routes (/api/health, /api/echo) and a basic frontend page that calls the echo endpoint with the user's JWT. Includes everything a real cartridge needs:

What it does NOT include (next walkthroughs):

2Prerequisites

3Step 0: Create the repo

$ mkdir my-echo-cartridge && cd my-echo-cartridge $ git init $ echo "node_modules/" > .gitignore $ echo "forge-sdk/" >> .gitignore # SDK files regenerated by pull-sdk.sh $ echo "build/" >> .gitignore # Terraform-built Lambda zips $ echo ".terraform/" >> .gitignore # Terraform state cache $ echo "*.tfstate*" >> .gitignore # Local state (we use remote state) $ echo "lambda/_shared/" >> .gitignore # Staged SDK files (regenerated by TF)

The forge-sdk/ directory is regenerated on every pull-sdk.sh call, so it goes in gitignore. The lock file is the only thing committed (it pins the version).

4Step 1: Pull the SDK

$ curl -O https://raw.githubusercontent.com/PayCargoDevOps/ai-sandbox/main/console/sdk/pull-sdk.sh $ chmod +x pull-sdk.sh $ ./pull-sdk.sh # latest stable (highest sdk-vX.Y.Z tag) # or pin: ./pull-sdk.sh v1.0.0 # or track main: ./pull-sdk.sh main

What lands:

./forge-sdk/
  shared/modules/                # server-side helpers (zero-npm)
    jwt-verify.js
    hmac.js
    log.js
    oauth/...
  console/sdk/v1/                # browser SDK (reference; runtime loads from Console CDN)
    css/, js/
  cartridges/_template/          # starting scaffold
    lambda/handler.js
    lambda/package.json
    backend.tf.snippet
    README.md
  Doctrine.md                    # the rules
  .claude/skills/forge-sdk/      # Claude agent skill (auto-loads)
  forge-sdk-manifest.json        # what's in the bundle
  VERSION                        # the SDK version

./forge-sdk.lock                 # version pin (commit and check in)

Commit pull-sdk.sh and forge-sdk.lock to your repo. forge-sdk/ stays gitignored.

Upgrading later

When a new SDK version drops, re-run ./pull-sdk.sh v<new>. It blows away ./forge-sdk/ and rewrites the lock file. Commit the lock file change.

5Step 2: Scaffold from the template

Create the cartridge skeleton at the repo root:

$ cp forge-sdk/cartridges/_template/lambda/handler.js lambda/ $ cp forge-sdk/cartridges/_template/lambda/package.json lambda/ $ cp forge-sdk/cartridges/_template/backend.tf.snippet . $ mkdir frontend

You now have:

./
  pull-sdk.sh
  forge-sdk.lock
  forge-sdk/                     # gitignored, regenerable
  lambda/
    handler.js                   # the template starting point
    package.json                 # type=module, zero npm
  backend.tf.snippet             # paste-in TF for staging shared modules
  frontend/                      # to be populated in Step 6
  .gitignore

6Step 3: Write the Lambda handler

Replace lambda/handler.js with the echo cartridge logic. This is the full file:

// =============================================================================
// echo cartridge — backend Lambda
// =============================================================================
// Two routes: GET /api/health and POST /api/echo.
// Auth: X-Origin-Secret (CloudFront-only) + Cognito JWT.
// Logging: structured *-complete log line on every echo call.
// =============================================================================

import {
  CognitoIdentityProviderClient,
  ListUserPoolClientsCommand,
} from '@aws-sdk/client-cognito-identity-provider';

import { createJwtVerifier }                from './_shared/jwt-verify.js';
import { checkOriginSecret }                from './_shared/hmac.js';
import { logInfo, logWarn, logError, logComplete } from './_shared/log.js';

const REGION               = process.env.AWS_REGION || process.env.REGION || 'us-east-1';
const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID;
const ORIGIN_SECRET        = process.env.ORIGIN_SECRET;

const cognitoIdp = new CognitoIdentityProviderClient({ region: REGION });

let allowedClientIdsCache = null;
async function resolveAllowedClientIds() {
  if (allowedClientIdsCache) return allowedClientIdsCache;
  const res = await cognitoIdp.send(new ListUserPoolClientsCommand({
    UserPoolId: COGNITO_USER_POOL_ID, MaxResults: 20,
  }));
  allowedClientIdsCache = new Set((res.UserPoolClients || []).map(c => c.ClientId));
  return allowedClientIdsCache;
}

const jwtVerifier = createJwtVerifier({
  region: REGION,
  userPoolId: COGNITO_USER_POOL_ID,
  getAllowedClientIds: resolveAllowedClientIds,
});

export const handler = async (event) => {
  const path   = event.rawPath || event.path || '/';
  const method = event.requestContext?.http?.method || event.httpMethod || 'GET';
  logInfo('request', { path, method });

  // Layer 1: only CloudFront can hit us.
  if (!checkOriginSecret(event, ORIGIN_SECRET)) {
    logWarn('origin-secret-mismatch');
    return jsonResponse(403, { error: 'Forbidden' });
  }

  try {
    // Health endpoint: no auth required (operators ping it).
    if (path === '/api/health' && method === 'GET') {
      return jsonResponse(200, { ok: true });
    }

    // Everything else requires a Cognito JWT.
    const authHeader =
      event.headers?.['x-auth-token'] ||
      event.headers?.authorization || '';
    const token = authHeader.replace(/^Bearer\s+/i, '');
    if (!token) return jsonResponse(401, { error: 'Missing X-Auth-Token header' });

    let jwtPayload;
    try { jwtPayload = await jwtVerifier.verify(token); }
    catch (err) {
      logWarn('jwt-validation-failed', { error: err.message });
      return jsonResponse(401, { error: 'Invalid or expired token' });
    }

    // The echo route.
    if (path === '/api/echo' && method === 'POST') {
      const startedAt = Date.now();

      let body;
      try {
        body = event.body
          ? JSON.parse(event.isBase64Encoded
              ? Buffer.from(event.body, 'base64').toString('utf8')
              : event.body)
          : {};
      } catch {
        return jsonResponse(400, { error: 'Invalid JSON body' });
      }

      const result = {
        you_said: body.message || '',
        echoed_at: new Date().toISOString(),
        as_user:   jwtPayload.username,
      };

      // Emit the *-complete log line. The obs Cartridges tab joins on this
      // schema. Required fields: msg, user, model, inputTokens, outputTokens,
      // durationMs. For non-AI cartridges, set model/tokens to documented
      // sentinel values so the obs join still works.
      logComplete({
        msg:          'echo-complete',
        user:         jwtPayload.username,
        model:        'no-ai',
        inputTokens:  0,
        outputTokens: 0,
        durationMs:   Date.now() - startedAt,
      });

      return jsonResponse(200, result);
    }

    return jsonResponse(404, { error: 'Not found', path });
  } catch (err) {
    logError('handler-exception', { error: err.message, stack: err.stack });
    return jsonResponse(500, { error: 'Internal error' });
  }
};

function jsonResponse(statusCode, body) {
  return {
    statusCode,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
    body: JSON.stringify(body),
  };
}

lambda/package.json is whatever the template gave you. Confirm it has:

{ "type": "module", "private": true }

No npm dependencies. Everything imports from either @aws-sdk/... (provided by the Node 20 Lambda runtime) or ./_shared/... (staged by Terraform at apply time).

The *-complete log line is the obs Cartridges-tab contract

If your cartridge does NOT call Bedrock, set model to a sentinel value like 'no-ai' and inputTokens/outputTokens to 0. The log line is still emitted; the obs tab knows how to count zeros. Skipping the line entirely means your cartridge is invisible to cost attribution.

7Step 4: Write backend.tf

Create backend.tf at the repo root. Starts with the local_file staging pattern from the SDK template, then adds the IAM role, Lambda, API Gateway, and CloudFront.

# =============================================================================
# backend.tf — echo cartridge backend
# =============================================================================

# ─── Stage the SDK helpers into lambda/_shared/ at apply time ───────────────
locals {
  shared_modules_dir = "${path.module}/forge-sdk/shared/modules"
}

resource "local_file" "shared_jwt_verify" {
  content  = file("${local.shared_modules_dir}/jwt-verify.js")
  filename = "${path.module}/lambda/_shared/jwt-verify.js"
}

resource "local_file" "shared_hmac" {
  content  = file("${local.shared_modules_dir}/hmac.js")
  filename = "${path.module}/lambda/_shared/hmac.js"
}

resource "local_file" "shared_log" {
  content  = file("${local.shared_modules_dir}/log.js")
  filename = "${path.module}/lambda/_shared/log.js"
}

# ─── Build the Lambda zip (shared files included via depends_on) ────────────
data "archive_file" "backend_zip" {
  type        = "zip"
  source_dir  = "${path.module}/lambda"
  output_path = "${path.module}/build/backend.zip"
  excludes    = ["build"]
  depends_on  = [
    local_file.shared_jwt_verify,
    local_file.shared_hmac,
    local_file.shared_log,
  ]
}

# ─── Origin secret used between CloudFront and API Gateway ─────────────────
resource "random_password" "origin_secret" {
  length  = 48
  special = false
}

# ─── IAM role for the Lambda (permission boundary inherited) ───────────────
data "aws_iam_policy_document" "lambda_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] }
  }
}

resource "aws_iam_role" "backend" {
  name                 = "${var.project_name}-echo-backend"
  assume_role_policy   = data.aws_iam_policy_document.lambda_assume.json
  permissions_boundary = var.permission_boundary_arn
  tags                 = merge(var.tags, { App = "echo" })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.backend.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

data "aws_iam_policy_document" "backend_policy" {
  # Cognito ListUserPoolClients for the JWT verify path
  statement {
    actions   = ["cognito-idp:ListUserPoolClients"]
    resources = ["arn:aws:cognito-idp:${var.region}:${var.account_id}:userpool/${var.cognito_user_pool_id}"]
  }
}

resource "aws_iam_role_policy" "backend" {
  name   = "${var.project_name}-echo-backend"
  role   = aws_iam_role.backend.id
  policy = data.aws_iam_policy_document.backend_policy.json
}

# ─── The Lambda function ────────────────────────────────────────────────────
resource "aws_lambda_function" "backend" {
  function_name = "${var.project_name}-echo-backend"
  role          = aws_iam_role.backend.arn
  handler       = "handler.handler"
  runtime       = "nodejs20.x"
  timeout       = 30
  memory_size   = 512

  filename         = data.archive_file.backend_zip.output_path
  source_code_hash = data.archive_file.backend_zip.output_base64sha256

  kms_key_arn = var.backend_kms_key_arn

  environment {
    variables = {
      REGION                = var.region
      COGNITO_USER_POOL_ID  = var.cognito_user_pool_id
      ORIGIN_SECRET         = random_password.origin_secret.result
    }
  }

  tags = merge(var.tags, { App = "echo" })
}

# ─── API Gateway HTTP API in front of the Lambda ───────────────────────────
resource "aws_apigatewayv2_api" "main" {
  name          = "${var.project_name}-echo"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "lambda" {
  api_id                 = aws_apigatewayv2_api.main.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.backend.invoke_arn
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "any" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "ANY /{proxy+}"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.main.id
  name        = "$default"
  auto_deploy = true
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.backend.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}

output "api_gateway_endpoint" {
  value = aws_apigatewayv2_stage.default.invoke_url
}

You'll also need a variables.tf with the substrate-side inputs:

variable "project_name"             { type = string }
variable "region"                   { type = string; default = "us-east-1" }
variable "account_id"               { type = string }
variable "cognito_user_pool_id"     { type = string }
variable "permission_boundary_arn"  { type = string }
variable "backend_kms_key_arn"      { type = string }
variable "tags"                     { type = map(string); default = {} }

And a terraform.tfvars with the values for ai-sandbox:

project_name             = "ai-sandbox"
region                   = "us-east-1"
account_id               = "959228203854"
cognito_user_pool_id     = "us-east-1_fJLnhjqnl"
permission_boundary_arn  = "arn:aws:iam::959228203854:policy/ai-sandbox-permission-boundary"
backend_kms_key_arn      = "arn:aws:kms:us-east-1:959228203854:key/<agent-cmk-id>"
tags                     = { Owner = "your-name@paycargo.com", App = "echo" }

This walkthrough omits the CloudFront distribution + frontend bucket

For the echo cartridge to truly work the same as the chatbot or obs, you'd add a CloudFront distribution in front of the API Gateway with the X-Origin-Secret custom header, plus an S3 bucket for the frontend. See the chatbot's frontend.tf for the pattern. For the echo walkthrough, you can test the API directly without CloudFront by setting the ORIGIN_SECRET env var to a value and sending it as a header from curl.

8Step 5: Write the cartridge manifest

Required by Step 6.i doctrine. Cartridges missing the manifest fail review. Create cartridge.tf at the repo root:

resource "aws_ssm_parameter" "cartridge_manifest" {
  name = "/forge/cartridges/echo/manifest"
  type = "String"
  value = jsonencode({
    name                = "echo"
    owner               = "your-name@paycargo.com"
    department          = "labs"
    data_classification = "internal"        # public | internal | confidential | pii
    retention_days      = 90
    oauth_providers     = []
    monthly_budget_usd  = 5
    description         = "Hello-world echo cartridge for SDK validation."
  })
  tags = var.tags
}

The obs Cartridges tab reads this. Missing fields fail review.

9Step 6: Write the frontend (optional)

For the API-only echo cartridge, you can skip the frontend and test with curl. If you want a frontend, the minimal pattern:

<!-- frontend/index.html -->
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="https://console.paycargo.org/sdk/v1/css/tokens.css">
  <link rel="stylesheet" href="https://console.paycargo.org/sdk/v1/css/components.css">
  <script src="https://console.paycargo.org/sdk/v1/js/cartridge-auth.js"></script>
</head>
<body>
  <h1>Echo</h1>
  <input id="msg" placeholder="say something" />
  <button id="go">Echo</button>
  <pre id="out"></pre>

  <script>
    document.getElementById('go').onclick = async () => {
      const auth = await window.PCSDK.auth.ready();
      const res  = await fetch('/api/echo', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'x-auth-token': auth.accessToken,
        },
        body: JSON.stringify({ message: document.getElementById('msg').value }),
      });
      document.getElementById('out').textContent = await res.text();
    };
  </script>
</body>
</html>

The browser SDK is loaded live from console.paycargo.org/sdk/v1/. The pulled copy at ./forge-sdk/console/sdk/v1/ is for IDE autocomplete; runtime always uses the live CDN.

To serve this through CloudFront with the same X-Origin-Secret pattern as the chatbot, you'd add an S3 bucket and a CloudFront distribution with two origins (S3 for static + API Gateway for API). Copy the shape from cartridges/global/chatbot/frontend.tf in the main repo (visible after pulling the SDK).

10Step 7: Deploy

$ terraform init $ terraform fmt $ terraform validate $ terraform plan -out=tfplan $ terraform apply tfplan

What gets created:

The output prints the API Gateway URL. Save it for the next step.

Where is state?

If you're working in your own repo, declare an S3 backend in versions.tf:

terraform {
  required_version = ">= 1.6"
  backend "s3" {
    bucket = "your-cartridge-state-bucket"
    key    = "echo/terraform.tfstate"
    region = "us-east-1"
  }
}

For ai-sandbox first deploys, you can use a local state file (no backend) and migrate to remote state later.

11Step 8: Verify

Health endpoint (no auth)

First check that the API Gateway is reachable. The health endpoint bypasses JWT but still requires the origin secret:

$ ORIGIN_SECRET=$(terraform output -raw origin_secret 2>/dev/null || aws ssm get-parameter ...) $ API_URL=$(terraform output -raw api_gateway_endpoint) $ curl -s "$API_URL/api/health" -H "X-Origin-Secret: $ORIGIN_SECRET" {"ok":true}

Echo endpoint (Cognito JWT required)

Get a fresh Cognito access token. From the Console, sign in and open dev tools, copy the accessToken from localStorage or sessionStorage. Then:

$ TOKEN="eyJraWQ..." # your Cognito access token $ curl -s "$API_URL/api/echo" \ -H "X-Origin-Secret: $ORIGIN_SECRET" \ -H "X-Auth-Token: Bearer $TOKEN" \ -H "content-type: application/json" \ -d '{"message": "hello forge"}' {"you_said":"hello forge","echoed_at":"2026-06-15T19:00:00Z","as_user":"mtang"}

Check CloudWatch Logs

Open the Lambda's CloudWatch Logs in the AWS console. You should see lines like:

{"level":"info","msg":"request","path":"/api/echo","method":"POST"}
{"level":"info","msg":"echo-complete","user":"mtang","model":"no-ai","inputTokens":0,"outputTokens":0,"durationMs":4}

Check the obs Cartridges tab

Open https://d2kh3b2wl3msbg.cloudfront.net/ → Cartridges. Within a few minutes the "echo" cartridge appears in the table (because the manifest exists at /forge/cartridges/echo/manifest). Per-user cost attribution shows $0 (no Bedrock spend) but the activity rows show the recent calls.

12Common gotchas

SymptomCauseFix
terraform apply hangs on state lock Another apply is running, or a previous apply was killed Wait 60-90 seconds and retry. If stuck longer than 5 min, check S3 lock table.
403 Forbidden on every call X-Origin-Secret missing or mismatched Confirm the header is being sent with the value from random_password.origin_secret.result. If CloudFront isn't in front yet, send it manually with curl.
401 Invalid or expired token Cognito JWT expired, or wrong user pool Refresh the token by signing into the Console again. Confirm COGNITO_USER_POOL_ID matches the pool the JWT came from.
Lambda import fails: "Cannot find module './_shared/jwt-verify.js'" The local_file staging didn't run (or ran after archive_file) Confirm depends_on on the archive_file points at all three local_file resources. Run terraform apply again; the staging is idempotent.
JWKS fetch timeout Lambda in VPC without internet egress, or KMS Decrypt blocked Either move Lambda out of VPC (echo cartridge can run without VPC), or add a NAT + VPC endpoint for Cognito.
Cartridge doesn't appear in obs Cartridges tab Manifest SSM parameter missing or wrong path Confirm parameter at /forge/cartridges/echo/manifest. Confirm all required fields are present.
Cost attribution shows $0 but no activity rows *-complete log line missing or not in the right shape Open CloudWatch Logs, search for echo-complete. Confirm the line has all required fields (msg, user, model, inputTokens, outputTokens, durationMs).
You added an npm package and now CI rejects Doctrine 3.1: zero npm in cartridge Lambdas Lift the primitive into shared/modules/ via a SDK PR. See Doctrine.md and the agent skill.

13Pre-PR checklist

Lift from .claude/skills/forge-sdk/SKILL.md pre-PR section. The skill auto-loads when an agent opens your repo; if you're going by hand, this is the same list.

14Where to go from here

References