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):
pull-sdk.sh) and bashgit@github.com:PayCargoDevOps/ai-sandbox.git (so pull-sdk.sh can clone)us-east-1_fJLnhjqnl.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).
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.
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.
Create the cartridge skeleton at the repo root:
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
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).
*-complete log line is the obs Cartridges-tab contractIf 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.
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" }
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.
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.
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).
What gets created:
local_file resources staging the SDK helpers into lambda/_shared/aws_iam_role for the Lambda (with permission boundary attached)aws_iam_role_policy for Cognito accessaws_lambda_function for the backendaws_apigatewayv2_api + integration + route + stage + permissionaws_ssm_parameter with the cartridge manifestrandom_password generating the origin secretThe output prints the API Gateway URL. Save it for the next step.
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.
First check that the API Gateway is reachable. The health endpoint bypasses JWT but still requires the origin secret:
Get a fresh Cognito access token. From the Console, sign in and open dev tools, copy the accessToken from localStorage or sessionStorage. Then:
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}
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.
| Symptom | Cause | Fix |
|---|---|---|
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. |
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.
npm install in lambda/checkOriginSecret, createJwtVerifier, logInfo/Warn/Error*-complete via logCompleteguardrailIdentifierus.* inference profile prefixaws_iam_role has permissions_boundaryaws_lambda_function_urllocal_file staging in backend.tf with depends_on on archive_filelambda/_shared/ is gitignoredcartridges/<dept>/<name>/ (or at the root if it's a standalone repo)getOAuthClient, never raw fetchterraform fmt clean; terraform validate passesgetOAuthClient returns a typed adapter..claude/skills/forge-sdk/SKILL.md in your repo (auto-loads in Claude Code) is the same doctrine in tactical form, with templates and pre-PR checklist.