codeAmani Tech-Stack — All Guides

79 integration guides from codeAmani Labs, combined for AI notebook tools.

Contents

  1. Africa's Talking Integration Guide
  2. AI Agents — Multi-Provider Developer Guide
  3. AI Video Generation Integration Guide
  4. Anonymity Integration Guide
  5. Anthropic Integration Guide
  6. Auth0 Integration Guide
  7. AWS Integration Guide
  8. Better Auth Integration Guide
  9. Blockchain Integration Guide
  10. Caching Integration Guide
  11. Canva Integration Guide
  12. Chrome DevTools Developer Training & Resource Guide
  13. Claude Design Integration Guide
  14. Claude Skills Integration Guide
  15. Clerk Integration Guide
  16. Cloudflare Integration Guide
  17. Cloudflare R2 (Private Media Storage) Integration Guide
  18. CodeRabbit Integration Guide
  19. Context7 Integration Guide
  20. Cursor Integration Guide
  21. Daraja API (Safaricom M-Pesa) Integration Guide
  22. DeepSeek Integration Guide
  23. Docker (Windows) — Containers Developer Guide
  24. Domain Portfolio Integration Guide
  25. ElevenLabs Integration Guide
  26. Encryption Integration Guide
  27. Figma Integration Guide
  28. Firecrawl Integration Guide
  29. Geolocation Integration Guide
  30. GitHub Developer Course
  31. Google AI Studio (Gemini API) Integration Guide
  32. Google Antigravity Integration Guide
  33. Google Cloud Developer Training & Resource Guide
  34. Grok Bot Developer Guide
  35. Hazina MCP Integration Guide
  36. Hugging Face Integration Guide
  37. Infisical Integration Guide
  38. Lighthouse Monitoring Dashboard & Custom SEO Plugin
  39. Local Databases (WSL + Docker) Guide
  40. Meta for Developers Integration Guide
  41. Microsoft Learn (MCP) Integration Guide
  42. MongoDB Integration Guide
  43. Neon Integration Guide
  44. Netlify Integration Guide
  45. Networking Integration Guide
  46. Notion Integration Guide
  47. OpenAI Integration Guide
  48. OpenClaw Integration Guide
  49. Openship Integration Guide
  50. pgvector Integration Guide
  51. Pinecone Integration Guide
  52. Plausible Analytics Integration Guide
  53. Porkbun Integration Guide
  54. Progressive Web Apps Integration Guide
  55. Railway Integration Guide
  56. Raspberry Pi — Novice-to-Pro Developer Guide
  57. Reddit Integration Guide
  58. Remote Access Integration Guide
  59. Render Integration Guide
  60. Resend Integration Guide
  61. Security Lab Integration Guide
  62. SendGrid Integration Guide
  63. Sentry Integration Guide
  64. SEO Developer Course & Resource Guide
  65. SLSA Provenance & Supply-Chain Integrity
  66. Stripe Integration Guide
  67. Supabase Integration Guide
  68. Together AI Integration Guide
  69. Twilio Integration Guide
  70. Upstash (Redis + QStash) Integration Guide
  71. Vercel AI SDK + AI Gateway Integration Guide
  72. Vercel Integration Guide
  73. Visual Studio Code Integration Guide
  74. Visual Studio Integration Guide
  75. Webhooks Developer Guide
  76. WhatsApp Business API Integration Guide
  77. WSL — Linux on Windows Developer Guide
  78. WSL Ubuntu Sandbox Guide
  79. xAI Integration Guide

Africa's Talking Integration Guide

Technology: africas-talking · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/africas-talking

Insight:

Africa's Talking is the reach-everyone channel — USSD works on feature phones with no data or smartphone, which SMS and WhatsApp can't claim. The trap: AT wants +254… (with +) while Daraja wants 254… (no +) — normalize per-API. Pairs with M-Pesa for full USSD → pay → SMS flows.

 █████╗ ███████╗██████╗ ██╗ ██████╗ █████╗ ███████╗
██╔══██╗██╔════╝██╔══██╗██║██╔════╝██╔══██╗██╔════╝
███████║█████╗  ██████╔╝██║██║     ███████║███████╗
██╔══██║██╔══╝  ██╔══██╗██║██║     ██╔══██║╚════██║
██║  ██║██║     ██║  ██║██║╚██████╗██║  ██║███████║
╚═╝  ╚═╝╚═╝     ╚═╝  ╚═╝╚═╝ ╚═════╝╚═╝  ╚═╝╚══════╝

████████╗ █████╗ ██╗     ██╗  ██╗██╗███╗   ██╗ ██████╗
╚══██╔══╝██╔══██╗██║     ██║ ██╔╝██║████╗  ██║██╔════╝
   ██║   ███████║██║     █████╔╝ ██║██╔██╗ ██║██║  ███╗
   ██║   ██╔══██║██║     ██╔═██╗ ██║██║╚██╗██║██║   ██║
   ██║   ██║  ██║███████╗██║  ██╗██║██║ ╚████║╚██████╔╝
   ╚═╝   ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝

Africa's Talking Integration Guide

Focus: Pan-African communications — SMS, USSD, Voice, Airtime, Mobile Data, and WhatsApp — reaching 300M+ subscribers across Africa. USSD is the standout: it works on feature phones with no smartphone or data plan, making it codeAmani's reach-everyone channel alongside M-Pesa.

Overview

Africa's Talking (AT) is a REST API with official africastalking SDKs for Node.js and Python. Authenticate with your app username + apiKey (header apiKey). A free sandbox (username literally sandbox) mirrors production for testing. Services exposed by the SDK: SMS, VOICE, AIRTIME, MOBILE_DATA, USSD, TOKEN, INSIGHTS, WHATSAPP, APPLICATION.

Here is the big picture at a glance — one authenticated REST API fanning out to every channel that reaches your users:

flowchart LR
  APP["Your app<br/>username + apiKey"] -->|"REST"| AT["Africa's Talking API"]
  AT --> SMS["SMS<br/>OTP · alerts · bulk"]
  AT --> USSD["USSD<br/>feature phones · no data"]
  AT --> VOICE["Voice · Airtime<br/>Mobile Data"]
  SMS --> USER["Subscriber"]
  USSD --> USER
  VOICE --> USER
  USER -->|"delivery callback"| APP

Official Documentation

Resource URL
Developer portal https://developers.africastalking.com/
SMS overview https://developers.africastalking.com/docs/sms/overview
USSD overview https://developers.africastalking.com/docs/ussd/overview
Voice overview https://developers.africastalking.com/docs/voice/overview
Voice actions https://developers.africastalking.com/docs/voice/actions/overview
Airtime sending https://developers.africastalking.com/docs/airtime/sending
Mobile Data sending https://developers.africastalking.com/docs/data/sending
Auth headers https://developers.africastalking.com/docs/request_headers
Node SDK https://github.com/africastalkingltd/africastalking-node.js
Python SDK https://github.com/AfricasTalkingLtd/africastalking-python

1. Credentials

Get username + apiKey from the dashboard. For local dev use the sandbox (username sandbox, sandbox API key).

AT_USERNAME=sandbox          # your live app username in production
AT_API_KEY=...               # server-side only — never ship to the browser
Environment Base URL
Live https://api.africastalking.com/version1
Sandbox https://api.sandbox.africastalking.com/version1

2. Install + initialize (Node)

npm install --save africastalking      # JS/TS
pip install africastalking             # Python
const client = require("africastalking")({
  apiKey: process.env.AT_API_KEY,
  username: process.env.AT_USERNAME, // 'sandbox' for testing
});
const { SMS, USSD } = client;

3. Send SMS

await SMS.send({
  to: ["+254711XXXYYY", "+254733YYYZZZ"], // note: +254… (international, WITH +)
  message: "Your codeAmani OTP is 123456",
  // senderId: "MYSENDERID",  // registered short code / alphanumeric sender ID
});

Raw REST equivalent — the current bulk endpoint takes a JSON body with a phoneNumbers array and a required senderId:

curl -X POST https://api.africastalking.com/version1/messaging/bulk \
  -H "apiKey: $AT_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"username":"my_app","phoneNumbers":["+254711XXXYYY"],"message":"Hello","senderId":"ABC"}'

Two SMS endpoints. POST /version1/messaging/bulk is the current JSON endpoint (phoneNumbers array, senderId required). The legacy POST /version1/messaging still works and is what the Node/Python SDKs call under the hood: application/x-www-form-urlencoded, to as a comma-separated string, optional from that defaults to AFRICASTKNG. In the sandbox, the default sender ID only delivers test SMS to Kenyan Airtel numbers — for any other network/region you must register (and get approval for) a sender ID first.

The response SMSMessageData.Recipients[].statusCode reports per-number status (101 Sent, 403 InvalidPhoneNumber, 405 InsufficientBalance, plus 100 Processed, 102 Queued, 401 RiskHold, 402 InvalidSenderId, 406 UserInBlacklist, 409 DoNotDisturbRejection, etc.) — status/statusCode mean accepted for sending, not delivered; use a delivery callback for delivery state.

4. Handle a USSD session

AT POSTs sessionId, phoneNumber, networkCode, serviceCode, text to your callback URL. Reply with plain text — CON keeps the session open, END closes it. The Node SDK wraps this as Express middleware:

Picture the back-and-forth — each dial POSTs to you, and your CON/END reply decides whether the menu keeps going or wraps up:

sequenceDiagram
  participant U as "User · feature phone"
  participant AT as "Africa's Talking"
  participant S as "Your callback URL"
  U->>AT: "Dial service code"
  AT->>S: "POST sessionId · phoneNumber · text"
  S-->>AT: "CON Welcome menu"
  AT-->>U: "Show menu - session open"
  U->>AT: "Reply 1"
  AT->>S: "POST text - 1"
  S-->>AT: "END Balance- KES 250.00"
  AT-->>U: "Show result - session closed"
const express = require("express");
const app = express();

app.post("/ussd", USSD((params, sendResponse) => {
  const { text, phoneNumber } = params; // text = "" first hit, then "1", "2*50", …
  if (text === "") {
    sendResponse({ response: "Welcome\n1. Balance\n2. Buy airtime", endSession: false });
  } else if (text === "1") {
    sendResponse({ response: "Balance: KES 250.00", endSession: true });
  } else {
    sendResponse({ response: "Done!", endSession: true });
  }
}));

Register the callback URL (HTTPS) for your service code in the dashboard. For local testing, tunnel with ngrok http 3000 (same as the M-Pesa callback workflow).

5. Voice, Airtime & Mobile Data

Beyond SMS and USSD, the same username + apiKey unlocks three more reach-everyone channels. Note the separate base hosts: Voice lives on voice.africastalking.com, Mobile Data on bundles.africastalking.com, while Airtime stays on the version1 REST host.

flowchart LR
  APP["Your app<br/>username + apiKey"] --> V["Voice<br/>voice.africastalking.com"]
  APP --> A["Airtime<br/>version1/airtime/send"]
  APP --> D["Mobile Data<br/>bundles.../data/request"]
  V -->|"outbound call"| U["Subscriber"]
  U -->|"inbound · POST"| CB["Your voice<br/>callback URL"]
  CB -->|"XML actions"| V
  A --> U
  D --> U

Airtime — send a top-up

Initialize the service and send airtime to one or more recipients (up to 1,000 per request). Each recipient needs a phoneNumber, currencyCode, and amount. Optional maxNumRetry is a count of hours to keep retrying failed sends (retries every 60s; default retry window 8h), and idempotencyKey guards against duplicate top-ups. Note AT auto-rejects near-identical airtime requests sent within a 5-minute window — set an Idempotency-Key (SDK: idempotencyKey) when you genuinely need to push a repeat top-up through that window.

const { AIRTIME } = client;

await AIRTIME.send({
  recipients: [
    { phoneNumber: "+254711XXXYYY", currencyCode: "KES", amount: 50 },
    { phoneNumber: "+254733YYYZZZ", currencyCode: "KES", amount: 100 },
  ],
  maxNumRetry: 3,                       // optional: retry failed deliveries for 3 hours
  idempotencyKey: "airtime-txn-001",    // optional: dedupe duplicate sends
});

Raw REST equivalent (note the Idempotency-Key header and amount as a "KES 100.00" string):

curl -X POST https://api.africastalking.com/version1/airtime/send \
  -H "apiKey: $AT_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "Idempotency-Key: airtime-txn-001" \
  -d '{"username":"my_app","maxNumRetry":2,"recipients":[{"phoneNumber":"+254711XXXYYY","amount":"KES 110.00"}]}'

The response responses[].status is Sent/Success (accepted), not delivered — like SMS, the final state arrives via the airtime status callback. numSent, totalAmount, and totalDiscount summarize the batch.

Voice — outbound call, then actions via callback

Outbound voice is queue-then-callback, not inline. POST to voice.africastalking.com/call to queue the call — the body is application/x-www-form-urlencoded with username, from (your AT number), to (a comma-separated string of recipients), and optional clientRequestId. The /call endpoint does not accept a callActions list; the call flow is decided only when AT connects the call and POSTs a notification to your registered voice callback URL, where you reply with XML voice actions (see below):

curl -X POST https://voice.africastalking.com/call \
  -H "apiKey: $AT_API_KEY" -H "Accept: application/json" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "username=my_app" \
  --data-urlencode "from=+254730XXXXXX" \
  --data-urlencode "to=+254711XXXYYY"
# response entries[].status: Queued | InvalidPhoneNumber | DestinationNotSupported | InsufficientCredit (+ sessionId)

Inbound / IVR (and the outbound call flow): when AT POSTs to your registered voice callback URL, reply with XML voice actions. The Node SDK's VOICE.ActionBuilder builds that XML with a fluent chain — here a menu that collects a keypad digit:

const { ActionBuilder } = client.VOICE;

app.post("/voice", express.urlencoded({ extended: false }), (req, res) => {
  const xml = new ActionBuilder()
    .say("Welcome to codeAmani. Press 1 for balance, 2 for support.")
    .getDigits(
      { say: { text: "Enter your choice followed by hash." } },
      { numDigits: 1, finishOnKey: "#", timeout: 10, callbackUrl: "https://myapp.com/voice/handle" }
    )
    .build();
  res.set("Content-Type", "application/xml");
  res.send(xml); // <Response><Say>…</Say><GetDigits …/></Response>
});

Available actions: Say, Play, GetDigits, Dial, Record, Enqueue, Dequeue, Redirect, Reject, Conference. The outbound /call response returns an entries[] list with per-number status (Queued, InvalidPhoneNumber, DestinationNotSupported, InsufficientCredit) and a sessionId.

Mobile Data — send a bundle

Send data bundles via MOBILE_DATA.send (REST: POST bundles.africastalking.com/mobile/data/request). Each recipient needs quantity, unit (MB or GB), and validity (Day, Week, BiWeek, Month, or Quarterly). productName must match the data product configured on your AT account.

const { MOBILE_DATA } = client;

await MOBILE_DATA.send({
  productName: "Mobile Data",            // must match your AT product name exactly
  recipients: [
    { phoneNumber: "+254711XXXYYY", quantity: 500, unit: "MB", validity: "Month",
      metadata: { customerId: "CUS001", reason: "loyalty-reward" } },
    { phoneNumber: "+254733YYYZZZ", quantity: 1, unit: "GB", validity: "Week",
      metadata: { customerId: "CUS002" } },
  ],
  idempotencyKey: "data-txn-001",        // optional: dedupe duplicate sends
});

The response entries[] gives per-number provider, status (Queued), transactionId, and value (the KES cost). Final delivery state arrives via the mobile data status callback.

Gotcha — three different hosts, two amount formats. Voice and Mobile Data do not use the version1 REST host: Voice is voice.africastalking.com/call and Mobile Data is bundles.africastalking.com/mobile/data/request (sandbox: voice.sandbox… / bundles.sandbox…). Airtime also flips amount format depending on layer: the SDK takes a numeric amount plus separate currencyCode (amount: 50, currencyCode: "KES"), but the raw REST body wants a single "KES 100.00" string. Mixing these up is the most common 4xx. As with SMS, every Queued/Sent status means accepted, not delivered — wire up the per-service status callbacks for the real outcome.

codeAmani notes

Official docs:

AI Agents — Multi-Provider Developer Guide

Technology: ai-agents · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/ai-agents

Insight:

An AI agent is an LLM running in a loop: perceive → reason → call a tool → observe the result → repeat until done. Every provider here — xAI Grok, Anthropic's Claude Agent SDK, Microsoft's Agent Framework, and Google's ADK — implements that same loop; what differs is the SDK, the hosting, and how you attach tools. The unifier is MCP (Model Context Protocol): build one MCP server and every agent can use it. For codeAmani, keep the model + tool keys server-side, allowlist tools, and require human approval before any agent action that moves money or writes to production.

 █████╗ ██╗     █████╗  ██████╗ ███████╗███╗   ██╗████████╗███████╗
██╔══██╗██║    ██╔══██╗██╔════╝ ██╔════╝████╗  ██║╚══██╔══╝██╔════╝
███████║██║    ███████║██║  ███╗█████╗  ██╔██╗ ██║   ██║   ███████╗
██╔══██║██║    ██╔══██║██║   ██║██╔══╝  ██║╚██╗██║   ██║   ╚════██║
██║  ██║██║    ██║  ██║╚██████╔╝███████╗██║ ╚████║   ██║   ███████║
╚═╝  ╚═╝╚═╝    ╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚═╝  ╚═══╝   ╚═╝   ╚══════╝

AI Agents — Multi-Provider Developer Guide

Focus: How to build AI agents across the four stacks you'll actually reach for — xAI Grok, Anthropic (Claude Agent SDK), Microsoft (Agent Framework + Copilot agent mode), and Google (ADK + Agent Engine) — their scope and capabilities, how to wire tools, and the MCP connector that links them all. Grounded in each vendor's official docs; reviewed 2026-08-23.

What an agent actually is

Strip away the hype: an agent is a language model put in a loop with tools. It reads the goal, decides whether it can answer directly or needs a tool, calls the tool, reads the result, and loops — until it can give a final answer. That's it. Everything else (memory, multi-agent, hosting) is built on top.

flowchart LR
  U["User goal"] --> P["Perceive<br/>read context"]
  P --> R["Reason<br/>plan next step"]
  R --> D{"Need a tool?"}
  D -->|yes| T["Act<br/>call a tool"]
  T --> O["Observe<br/>read result"]
  O --> R
  D -->|no| A["Respond"]

The two non-negotiables: tools (what the agent can do — search, query a DB, send an SMS) and stop conditions (when to quit the loop). Get those right and the rest is plumbing.

Official Documentation

Provider / spec URL
xAI (Grok) API https://docs.x.ai/docs
Claude Agent SDK (Python) https://github.com/anthropics/claude-agent-sdk-python
Google Agent Development Kit https://adk.dev/
Microsoft Agent Framework https://learn.microsoft.com/agent-framework/
Model Context Protocol (MCP) https://modelcontextprotocol.io/
Copilot agent mode (VS Code) https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode

MCP — the universal connector

Before the providers, learn the thing that ties them together. MCP (Model Context Protocol) is an open standard: an MCP server exposes tools, resources, and prompts; any MCP-capable agent (client) can consume them over stdio (local subprocess) or HTTP. Build your "send M-Pesa receipt" or "query Supabase" tool once as an MCP server, and every agent below can call it.

flowchart TB
  subgraph Agents["MCP clients (agents)"]
    G["Grok"]
    C["Claude Agent SDK"]
    M["MS Agent Framework / Copilot"]
    A["Google ADK"]
  end
  subgraph Servers["Your MCP servers"]
    S1["mpesa-tools<br/>STK push · receipts"]
    S2["data-tools<br/>Supabase · BigQuery"]
  end
  G --> S1
  C --> S1
  M --> S2
  A --> S2
  C --> S2

Every provider in this guide speaks MCP — that's the bet: write tools once, reuse everywhere. See the github and supabase guides for first-party MCP servers you can attach today.


1. xAI — Grok agents

# pip install xai-sdk   (Python 3.10+)
import json
from pydantic import BaseModel, Field
from xai_sdk import Client
from xai_sdk.chat import system, user, tool, tool_result

client = Client()  # reads XAI_API_KEY

class WeatherReq(BaseModel):
    city: str = Field(description="City name")

def get_weather(city: str) -> str:
    return f"Sunny, 26°C in {city}"

chat = client.chat.create(
    model="grok-4.6",  # current flagship (grok-4 is retired); check docs.x.ai/developers/models
    messages=[system("You are a helpful assistant.")],
    tools=[tool(name="get_weather", description="Current weather for a city.",
                parameters=WeatherReq.model_json_schema())],
)
chat.append(user("Weather in Nairobi?"))
resp = chat.sample()
chat.append(resp)
for tc in resp.tool_calls:                      # the model asked to call a tool
    args = json.loads(tc.function.arguments)
    chat.append(tool_result(get_weather(**args), tool_call_id=tc.id))
print(chat.sample().content)                     # final answer after the tool result
from xai_sdk.tools import web_search, code_execution, mcp
chat = client.chat.create(model="grok-4.6", tools=[
    web_search(), code_execution(),
    mcp(server_url="https://mcp.example.com", authorization="Bearer TOKEN"),
])

2. Anthropic — Claude Agent SDK

# pip install claude-agent-sdk   (also: npm i @anthropic-ai/claude-agent-sdk)
from claude_agent_sdk import tool, create_sdk_mcp_server, ClaudeAgentOptions, query

@tool("mpesa_status", "Check an M-Pesa STK payment status", {"checkout_id": str})
async def mpesa_status(args):
    status = await lookup(args["checkout_id"])  # your code
    return {"content": [{"type": "text", "text": status}]}

server = create_sdk_mcp_server(name="mpesa", version="1.0.0", tools=[mpesa_status])

options = ClaudeAgentOptions(
    mcp_servers={"mpesa": server},
    allowed_tools=["mcp__mpesa__mpesa_status"],   # pre-approve → no permission prompt
)

async for msg in query(prompt="Is checkout ws_CO_123 paid?", options=options):
    print(msg)

3. Microsoft — Agent Framework + Copilot agent mode

Two complementary surfaces.

a) Build agents in code — Microsoft Agent Framework

The unified successor to Semantic Kernel + AutoGen (same teams), now GA (1.x) with SDKs for .NET, Python, and Go (Go still public preview) — session state, middleware/telemetry, graph-based multi-agent workflows, MCP support, and first-class model providers including Anthropic, Microsoft Foundry, Azure OpenAI, OpenAI, and Ollama.

# pip install agent-framework   (GA, 1.x)
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient   # or FoundryChatClient, Anthropic, etc.

agent = Agent(
    client=OpenAIChatClient(),                 # any IChatClient-style provider
    instructions="You are codeAmani's Swahili-fluent support agent.",
    tools=[get_weather],                       # plain functions become tools
)
result = await agent.run("Habari ya hali ya hewa Nairobi?")
print(result)

In .NET the base type is AIAgent and a single ChatClientAgent wraps any IChatClient provider. The framework also ships CopilotStudioAgent and an A2AAgent (agent-to-agent).

b) Drive an agent in the IDE — Copilot agent mode

In VS Code / Visual Studio, open Chat → switch to Agent mode → the tools icon lists available tools, including any MCP servers you've added. Reference a tool inline with #tool_name. This is how you wire MCP servers (Microsoft Learn, Azure, your own) into the editor agent.

// .vscode/mcp.json — add an MCP server to Copilot agent mode
{ "servers": { "mpesa": { "command": "npx", "args": ["-y", "mpesa-mcp"] } } }

4. Google — Agent Development Kit (ADK)

# pip install google-adk
from google.adk.agents import Agent
from google.adk.runners import InMemoryRunner
from google.genai import types

def get_weather(city: str) -> dict:
    """Current weather for a city.  (the docstring is the tool's description)"""
    return {"status": "success", "report": f"Sunny, 26°C in {city}"}

agent = Agent(
    name="weather_agent",
    model="gemini-flash-latest",
    instruction="Use the tools to answer.",
    tools=[get_weather],          # plain Python functions; docstring matters
)

runner = InMemoryRunner(agent=agent, app_name="weather")
# runner.run_async(user_id=..., session_id=..., new_message=types.Content(...))

Choosing a provider

xAI Grok Claude Agent SDK MS Agent Framework Google ADK
Language Python / REST Python / TS C# / Python / Go Python / Java / Go
Tools client + server-side MCP + built-ins functions + MCP functions + MCP
MCP mcp() tool mcp_servers VS Code + framework McpToolset
Hosting xAI API your infra / Claude Code Azure / your infra Vertex Agent Engine
Multi-agent DIY subagents workflows (graph) agent hierarchies
Best for research + live web/X coding/ops agents w/ guardrails .NET shops, IDE agents Gemini + GCP-native

Rule of thumb for codeAmani: Claude Agent SDK for ops/coding agents with strong guardrails; Google ADK when you're already on GCP/Gemini and want managed Agent Engine memory; Grok for live-web/X research; Microsoft when the stack is .NET/Azure or you want the in-IDE Copilot agent.


Capabilities & scope (what to expect)


codeAmani notes

Official docs:

AI Video Generation Integration Guide

Technology: ai-video · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/ai-video

Insight:

Generative video is an async job, not a request/response: you submit a prompt, get a job id, then poll or receive a webhook before downloading a clip that costs cents-to-dollars per output-second and whose URL expires within days. The market moved fast through 2026 — Veo 3.1 is the quality leader, aggregators (fal fal-ai/veo3.1, Replicate google/veo-3.1) let you swap models with one string, and OpenAI is exiting: the Sora 2 API shuts down 2026-09-24 with no announced successor. For codeAmani it is a distinct AI-routing modality — default to short 9:16 vertical clips for East African social / WhatsApp marketing, draft cheap on a fast tier, and re-host every render to R2 before the provider URL expires.

 █████╗ ██╗    ██╗   ██╗██╗██████╗ ███████╗ ██████╗
██╔══██╗██║    ██║   ██║██║██╔══██╗██╔════╝██╔═══██╗
███████║██║    ██║   ██║██║██║  ██║█████╗  ██║   ██║
██╔══██║██║    ╚██╗ ██╔╝██║██║  ██║██╔══╝  ██║   ██║
██║  ██║██║     ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝
╚═╝  ╚═╝╚═╝      ╚═══╝  ╚═╝╚═════╝ ╚══════╝ ╚═════╝

AI Video Generation Integration Guide

Focus: generative video is an async job, not a request/response. You submit a prompt, get a job id, then either poll the status or let a webhook call you back, and finally download the rendered clip. It is the exact reverse-API flow the repo already documents for webhooks — applied to a render that takes 30 seconds to several minutes.

Overview

Text-to-video and image-to-video models turn a prompt (and optionally a starting image) into a short MP4 with — increasingly — native audio. Unlike an LLM call that streams tokens back in seconds, a video render is long-running and expensive: a single 8-second 1080p clip can take a minute or two of GPU time and cost anywhere from a few cents to several dollars. Because of that, no serious provider makes you hold an HTTP connection open for the render. They all converged on the same shape:

  1. Submit the prompt → you immediately get a job / prediction / operation id.
  2. Wait for completion via one of two mechanisms:
    • Poll — GET the job id every few seconds until status is terminal.
    • Webhook — register a callback URL; the provider POSTs you when the clip is ready (this is the cheaper, more scalable path, and it is the same receiver pattern documented in webhooks/).
  3. Download the rendered clip from the signed URL in the result (the file usually expires — re-host it to your own R2/Supabase bucket).

This guide treats AI video as a new modality in the codeAmani AI routing policy — alongside Claude (reasoning), OpenAI (structured), and HuggingFace (open models) — and shows how to call it in production through both first-party APIs (Google Veo via the Gemini API) and aggregators (Replicate, fal.ai) that put dozens of models behind one async interface.

Models & providers

Prices are per second of output and move fast — always re-check the model page before committing a budget. As of the review date (2026-08-23):

Model Best at Native audio Duration / res ~Cost/sec How to call
Google Veo 3.1 Top-tier realism, lip-sync, 1080p/4K Yes (always on) 4/6/8s · 720p/1080p/4K ~$0.40 (Standard); ~$0.15 Fast Gemini API (@google/genai), Vertex AI, Replicate (google/veo-3.1), fal (fal-ai/veo3.1)
Runway Gen-4.5 Cinematic control, native 4K, prompt adherence No (Aleph 2.0 edits w/ audio) short clips · up to 4K ~$0.20 Runway Dev API (@runwayml/sdk), aggregators
Luma Ray3 / Ray3.14 Reasoning model, native 16-bit HDR, fast/cheap drafts Partial up to ~10s · 1080p low Luma API (lumaai), aggregators
Kling 3.0 Physics, multi-shot consistency, up to 4K@60fps Yes 3–15s (up to ~2 min) ~$0.09–0.14 Aggregators (Replicate, fal)
Seedance 2.5 Cost leader, strong image-to-video Yes short · up to 1080p low Aggregators (fal, Replicate)
Pika Stylised, effects, social Partial short low Aggregators
OpenAI Sora 2 ⚠️ Coherent physics, prompt fidelity Yes 4/8/12s (Pro 10/15/25s) ~$0.10 base · $0.30–0.70 Pro API discontinuing 2026-09-24 — do not build on it

⚠️ Sora is exiting. OpenAI notified developers on 2026-03-24 that the Videos API and the sora-2 / sora-2-pro models (and their dated snapshots) are deprecated and shut down on 2026-09-24; the consumer app was already discontinued 2026-04-26 and OpenAI has announced no successor. Do not start new work on Sora — reach for Veo, Kling, or an aggregator instead.

Selection heuristic: prototype on a cheap model through an aggregator (one SDK, swap the model id string), then promote the specific clip to Veo 3.1 only when it ships to a client. Reach for first-party Gemini/Vertex when you need Google's enterprise SLA, data-residency, or 4K.

Official Documentation

Source URL What it covers
fal queue API https://fal.ai/docs/model-apis/model-endpoints/queue fal.queue.submit/status/result, subscribe, webhookUrl
fal Veo 3.1 endpoint https://fal.ai/models/fal-ai/veo3.1/api Input schema, duration/resolution/aspect enums (the old fal-ai/veo3 endpoint is deprecated)
Replicate Veo 3.1 https://replicate.com/google/veo-3.1 Model id, image-to-video, durations (Fast = google/veo-3.1-fast)
Replicate JS client https://github.com/replicate/replicate-javascript predictions.create + webhook, predictions.get, run
Gemini Veo docs https://ai.google.dev/gemini-api/docs/veo @google/genai generateVideos, operation polling, download
Luma API https://docs.lumalabs.ai/docs/api Create generation → id → poll status (Ray3 / Ray3.14; Luma Agents API for Ray3.2)
Runway Dev API https://docs.dev.runwayml.com/ Unified API (Gen-4.5, Gen-4 Turbo, Aleph 2.0, Act-Two); changelog for deprecations

The async lifecycle: submit → poll / webhook → download

Every provider is a variation on this. Submit returns an id; the clip is not in that response. You then wait by polling or by receiving a webhook, then download.

sequenceDiagram
    participant App as Your app
    participant V as Video provider
    participant CB as Your webhook (optional)
    participant R2 as R2 / Supabase
    App->>V: POST prompt (+ image) · submit job
    V-->>App: 202 · { job_id, status: "queued" }
    alt Polling
        loop every few seconds
            App->>V: GET job_id
            V-->>App: status: queued / processing / succeeded
        end
    else Webhook
        V->>CB: POST job_id · status: succeeded · video url
        CB->>CB: verify · dedupe on job_id
    end
    App->>V: GET signed clip url
    App->>R2: re-host MP4 (provider url expires)

Two render paths, one rule: the clip URL the provider hands back is temporary (Veo deletes after ~2 days; aggregator URLs are signed and expire). Download and re-host to your own bucket immediately — never store the provider URL as your permanent asset link.

Provider selection flow

flowchart TD
    A["Need a video"] --> B{"Have a start image?"}
    B -->|"yes"| C["Image-to-video"]
    B -->|"no"| D["Text-to-video"]
    C --> E{"Budget?"}
    D --> E
    E -->|"draft / social · cheap"| F["Kling / Luma / Veo-Fast via aggregator"]
    E -->|"client deliverable · quality"| G["Veo 3.1 Standard / Runway Gen-4.5"]
    F --> H{"Scale / many jobs?"}
    G --> H
    H -->|"yes"| I["Use webhookUrl · no polling"]
    H -->|"no · one-off"| J["subscribe / poll inline"]

fal.ai — the cleanest aggregator (submit + webhook OR subscribe)

@fal-ai/client exposes the queue directly. For production, submit with a webhookUrl so you never block. For a quick script, fal.subscribe hides the polling.

// lib/video/fal.ts
import { fal } from "@fal-ai/client";

fal.config({ credentials: process.env.FAL_KEY! }); // server-side only

/** Production path: submit and let fal call your webhook when done. */
export async function submitVeoJob(prompt: string): Promise<string> {
  // fal-ai/veo3.1 is the current endpoint — the old fal-ai/veo3 is deprecated.
  const { request_id } = await fal.queue.submit("fal-ai/veo3.1", {
    input: {
      prompt,
      aspect_ratio: "9:16", // vertical for social / WhatsApp status
      duration: "8s",       // string enum: "4s" | "6s" | "8s"
      resolution: "720p",   // draft res; "1080p" / "4k" require duration "8s"
      generate_audio: true, // default true — audio is native to Veo 3.1
    },
    webhookUrl: "https://app.codeamanilabs.org/api/webhooks/fal",
  });
  return request_id; // store this — it's your idempotency key on the callback
}

/** Read the finished clip once the webhook says it's ready. */
export async function fetchVeoResult(requestId: string): Promise<string> {
  const result = await fal.queue.result("fal-ai/veo3.1", { requestId });
  return result.data.video.url; // re-host this immediately (it expires)
}

For a one-off (no webhook infrastructure), subscribe polls for you:

const result = await fal.subscribe("fal-ai/veo3.1", {
  input: { prompt, duration: "8s", resolution: "720p" },
  logs: true,
});
// result.data.video.url

The webhook receiver is exactly the pattern in webhooks/CLAUDE_CODE_INTEGRATION.md: verify, dedupe on request_id, ACK fast, then download + re-host out of band.


Replicate — one client, hundreds of models

replicate.predictions.create returns a prediction id and supports webhook + webhook_events_filter. Polling is predictions.get(id) with statuses starting → processing → succeeded | failed.

// lib/video/replicate.ts
import Replicate from "replicate";

const replicate = new Replicate({ auth: process.env.REPLICATE_API_TOKEN! });

/** Submit a Veo 3.1 image-to-video render with a webhook callback. */
export async function submitReplicateVideo(prompt: string, imageUrl?: string) {
  const prediction = await replicate.predictions.create({
    model: "google/veo-3.1",
    input: {
      prompt,
      image: imageUrl,        // omit for text-to-video
      duration: 8,            // 4 | 6 | 8 seconds
      resolution: "1080p",    // 720p | 1080p @ 24fps
      aspect_ratio: "9:16",
    },
    webhook: "https://app.codeamanilabs.org/api/webhooks/replicate",
    webhook_events_filter: ["completed"], // fire once, when terminal
  });
  return prediction.id;
}

/** Polling fallback when you have no webhook endpoint. */
export async function pollReplicate(id: string): Promise<string> {
  let prediction = await replicate.predictions.get(id);
  while (prediction.status !== "succeeded" && prediction.status !== "failed") {
    await new Promise((r) => setTimeout(r, 3000));
    prediction = await replicate.predictions.get(id);
  }
  if (prediction.status === "failed") throw new Error(String(prediction.error));
  return prediction.output as unknown as string; // signed MP4 url → re-host
}

replicate.run(model, { input }) is the blocking convenience form — fine for scripts, avoid in request handlers because a render can outlast a serverless function's timeout.


Google Veo via the Gemini API (@google/genai) — first-party

The first-party path uses a long-running operation: generateVideos returns an operation you poll with getVideosOperation until operation.done, then download via ai.files.download.

// lib/video/veo.ts
import { GoogleGenAI } from "@google/genai";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

export async function generateVeoClip(prompt: string): Promise<string> {
  let operation = await ai.models.generateVideos({
    // veo-3.1-generate-preview (standard) · veo-3.1-fast-generate-preview (drafts)
    // · veo-3.1-lite-generate-preview (cheapest, no 4K / no reference images)
    model: "veo-3.1-generate-preview",
    prompt,
  });

  // Poll the long-running operation (no inline webhook on this SDK path).
  while (!operation.done) {
    await new Promise((r) => setTimeout(r, 10_000));
    operation = await ai.operations.getVideosOperation({ operation });
  }

  const video = operation.response.generatedVideos[0].video;
  await ai.files.download({ file: video, downloadPath: "output.mp4" });
  return "output.mp4"; // upload to R2/Supabase — Gemini deletes after ~2 days
}

Veo constraints: duration is 4 / 6 / 8s; 8s is required for 1080p/4K or when supplying reference images; output includes natively generated audio. For background jobs at scale on Google, prefer Vertex AI (it exposes proper async operations + Cloud Storage output) over the inline polling loop.


Cost, limits & content moderation


codeAmani notes

Official docs:

Anonymity Integration Guide

Technology: anonymity · Category: tooling · Last reviewed: 2026-08-03

Source: https://tech-stack.codeamanilabs.org/guide/anonymity

Insight:

Anonymity is a property of a system under a stated adversary, never a product you install. The engineering job is subtractive: every identifier you never collect, every log line you never write, and every third-party script you never ship is anonymity you don't have to defend later. This guide is the audited, 2026-current replacement for a decade of stale "install Tor and you're invisible" folklore — including the specific tool-by-tool corrections.

 █████╗ ███╗   ██╗ ██████╗ ███╗   ██╗██╗   ██╗███╗   ███╗██╗████████╗██╗   ██╗
██╔══██╗████╗  ██║██╔═══██╗████╗  ██║╚██╗ ██╔╝████╗ ████║██║╚══██╔══╝╚██╗ ██╔╝
███████║██╔██╗ ██║██║   ██║██╔██╗ ██║ ╚████╔╝ ██╔████╔██║██║   ██║    ╚████╔╝ 
██╔══██║██║╚██╗██║██║   ██║██║╚██╗██║  ╚██╔╝  ██║╚██╔╝██║██║   ██║     ╚██╔╝  
██║  ██║██║ ╚████║╚██████╔╝██║ ╚████║   ██║   ██║ ╚═╝ ██║██║   ██║      ██║   
╚═╝  ╚═╝╚═╝  ╚═══╝ ╚═════╝ ╚═╝  ╚═══╝   ╚═╝   ╚═╝     ╚═╝╚═╝   ╚═╝      ╚═╝   

Anonymity Integration Guide

Focus: Building systems that don't deanonymize the people who use them — threat modelling, metadata minimization, Tor/onion-service integration, and anonymous intake — plus a tool-by-tool audit correcting a decade of stale advice.

Overview

Anonymity is not a tool. It is a property of a system, measured against a named adversary. "Am I anonymous?" is an unanswerable question. "Can a passive observer of my ISP link this request to my legal identity?" is answerable, testable, and engineerable.

Three distinct properties get conflated constantly, and conflating them is the single most common failure in this space:

Property Question it answers Broken by
Confidentiality Can they read the content? Weak/absent E2EE, backdoored endpoints
Privacy Can they link content to behaviour? Logging, tracking, data brokers
Anonymity Can they link behaviour to an identity? Metadata, correlation, one careless reuse

Encryption gives you the first. It gives you neither of the other two. A perfectly encrypted message that arrives from your home IP, at a predictable hour, to a recipient only you contact, is not anonymous — and this is precisely how most real deanonymizations happen.

For codeAmani the practical surface is defensive: we build products used by riders, clinic patients, SACCO members, donors, and whistleblowers. Our job is to avoid collecting the identifiers that would deanonymize them, not to help anyone evade lawful process.

flowchart TD
  A["Who is the adversary?"] --> B{"Capability"}
  B -->|"Curious insider / scraper"| C["Data minimization<br/>access control · retention limits"]
  B -->|"Commercial surveillance<br/>adtech · data brokers"| D["No 3rd-party scripts<br/>no SDK telemetry · IP truncation"]
  B -->|"Network observer<br/>ISP · café wifi"| E["TLS + ECH · Tor<br/>pluggable transports"]
  B -->|"Nation-state<br/>global passive adversary"| F["Compartmentalized OS<br/>Qubes-Whonix · Tails · burner hardware"]
  C --> G["Anonymity set:<br/>how many people<br/>could this have been?"]
  D --> G
  E --> G
  F --> G

The last box is the one that matters. Anonymity is a crowd property. You are only as anonymous as the number of people who look identical to you. Every "optimization" that makes you unusual — a rare font set, a custom user-agent, a niche browser extension — shrinks your crowd and hurts you. This inverts most people's intuition and is the reason the audit below rejects so much traditional "hardening" advice.

Official Documentation

Resource URL
Onion services (operator docs) https://community.torproject.org/onion-services/
Tor protocol specifications https://spec.torproject.org/
Tor Browser manual https://tb-manual.torproject.org/
Whonix documentation https://www.whonix.org/wiki/Documentation
SecureDrop (whistleblower intake) https://securedrop.org/
Arti (Rust Tor) API docs https://docs.rs/arti-client
Privacy Guides (tool selection) https://www.privacyguides.org/

The 2015 → 2026 audit

This guide was commissioned as an audit of Tor and the Dark Art of Anonymity (Lance Henderson, 2015 edition). The book's principles aged well; its tooling did not, and a meaningful fraction of its advice is now actively harmful.

Full chapter-by-chapter mapping lives in reference/book-audit-2015-to-2026.md. Headlines:

Actively harmful if followed today

Book says Reality in 2026
Install CanvasBlocker, User-Agent Switcher, Ghostery, Adblock Never add extensions to Tor Browser. Each one raises fingerprint entropy and shrinks your anonymity set. Tor Browser ships letterboxing and a uniform fingerprint by design — customizing it is self-defeating.
Use Chrome with ScriptNo/ScriptSafe/FlashControl Chrome is not an anonymity browser and never was. Flash reached EOL Dec 2020. Use Tor Browser, or Mullvad Browser (Tor Browser's fingerprint without the Tor network) for VPN use.
Manually configure NoScript per-site Per-site whitelists are themselves a fingerprint. Use the built-in Security Level slider (Standard / Safer / Safest) and nothing else.
Pay for a VPN anonymously, chain it with Tor VPN+Tor generally does not improve anonymity and often harms it. Use Tor's bridges + pluggable transports for censorship, not a VPN hop.
Use Bitcoin mixers (BitFog et al.) for anonymity Bitcoin is pseudonymous, not anonymous; chain analysis is a mature industry. Mixing now carries severe legal exposure — Samourai Wallet's founders pleaded guilty and were sentenced to 5 and 4 years in late 2025, even as OFAC delisted Tornado Cash in March 2025.
Generate keys at Brainwallet.org Brainwallets are catastrophically insecure and were mass-drained. Never generate key material in a web page.

Dead, renamed, or superseded

Book (2015) Status Modern equivalent
Torbutton, HTTPS Everywhere Merged into Tor Browser core; HTTPS Everywhere retired Jan 2023 Built-in Security Level + HTTPS-Only Mode
v2 .onion addresses (16 char) Removed from Tor, Oct 2021 v3 onions — 56 char, ed25519/SHA3
Shallot / Scallion (v2 vanity) Dead with v2 mkp224o (v3 ed25519 vanity)
TextSecure + RedPhone Merged into Signal (2015) Signal — PQXDH (2023) + SPQR "Triple Ratchet" (Oct 2025)
CryptoCat, Torchat, ChatSecure Discontinued / unmaintained SimpleX, Briar, Cwtch (metadata-resistant)
Tor Instant Messaging Bundle Cancelled — never shipped as above
TrueCrypt Discontinued 2014 VeraCrypt, LUKS2, BitLocker, FileVault
TorBirdy, Enigmail Discontinued 2020 Thunderbird built-in OpenPGP
Freenet + Frost + Fuqid Renamed Hyphanet (2023); front-ends abandoned Hyphanet, I2P, Nym mixnet
MultiBit, MultiSigna, most listed exchanges Defunct —
Darkcoin Renamed Dash (2015); PrivateSend is opt-in CoinJoin, not anonymity Monero if privacy is the actual requirement
Panopticlick Renamed EFF Cover Your Tracks
Skype Retired May 2025 Signal
Macchanger as a manual step MAC randomization is now default in iOS 14+, Android 10+, Windows 10+, NetworkManager (nothing to do)

Missing entirely from the book — and now central

Qubes OS (compartmentalization by virtualization; Qubes-Whonix is the strongest widely-available desktop posture) · GrapheneOS (hardened Android) · Snowflake / WebTunnel pluggable transports · Arti (Rust Tor, 2.0.0, embeddable as a library) · Post-quantum crypto (NIST FIPS 203/204/205, Aug 2024) · Commercial spyware (Pegasus, Predator, Graphite) as the realistic high-end threat, answered by iOS Lockdown Mode / Android Advanced Protection · Data brokers and SDK-based location resale, which deanonymize at scale far more cheaply than any SIGINT program · SecureDrop / Hush Line for anonymous intake.

Also notable: Tails merged into the Tor Project in September 2024 — they are now one organization.

Deliberately not modernized

The book's chapters on darknet-market escrow, "finalize early" tactics, evading law enforcement, and running hidden marketplaces are out of scope and intentionally left un-updated. They are operational crime guidance, not privacy engineering, and much of the surrounding ecosystem they describe (Silk Road 2.0, Agora, Blackbank, Sheep) was seized or exit-scammed within a year of publication. This guide covers the defensive and civil-liberties surface only: protecting users, journalists, and at-risk people from surveillance.

What the book got right, and still is

Worth preserving explicitly, because it's the durable part:


Setup

Running a local Tor client

Everything below assumes a Tor SOCKS5 proxy on 127.0.0.1:9050 (daemon) or 9150 (Tor Browser).

# macOS
brew install tor && brew services start tor

# Debian / Ubuntu
sudo apt install tor && sudo systemctl enable --now tor

# Verify: should report Congratulations
curl -s --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip

--socks5-hostname (not --socks5) is load-bearing: it sends DNS resolution through the proxy. Plain --socks5 resolves DNS locally and leaks every hostname you visit to your resolver — a total anonymity failure that still looks like it's working.

Node — routing requests through Tor

npm install socks-proxy-agent

socks-proxy-agent implements Node's http.Agent, so it works with anything built on http/https — axios, node-fetch, got:

import axios from "axios";
import { SocksProxyAgent } from "socks-proxy-agent";

// socks5h:// = resolve DNS at the proxy. socks5:// leaks DNS locally.
const agent = new SocksProxyAgent("socks5h://127.0.0.1:9050");

const { data } = await axios.get("https://check.torproject.org/api/ip", {
  httpAgent: agent,
  httpsAgent: agent,
});
console.log(data); // { IsTor: true, IP: "..." }

Node's native fetch silently ignores agent. Native fetch is undici, which only honours a dispatcher. Passing { agent } to it does not error — it just sends the request over your real IP. If you must use native fetch, build an undici Agent with a SOCKS connect function; otherwise stay on axios/node-fetch for proxied calls.

Fail closed, so a misconfiguration can never fall back to a direct connection:

export async function assertTor(agent: SocksProxyAgent): Promise<void> {
  const { data } = await axios.get("https://check.torproject.org/api/ip", {
    httpAgent: agent, httpsAgent: agent, timeout: 15_000,
  });
  if (!data.IsTor) throw new Error("Refusing to proceed: traffic is not over Tor");
}

Python — controller access with Stem

stem is the Tor Project's own controller library — use it to build circuits, rotate identity, and publish onion services programmatically.

pip install stem pysocks requests[socks]
import requests
from stem import Signal
from stem.control import Controller

# socks5h:// -> DNS resolved by Tor, not locally
PROXIES = {"http": "socks5h://127.0.0.1:9050",
           "https": "socks5h://127.0.0.1:9050"}

print(requests.get("https://check.torproject.org/api/ip", proxies=PROXIES).json())

# Request a fresh circuit (rate-limited by Tor to roughly one per 10s)
with Controller.from_port(port=9051) as c:
    c.authenticate()               # cookie auth by default
    c.signal(Signal.NEWNYM)

NEWNYM gives you a new circuit, not a new identity. Cookies, local storage, a logged-in session, and browser fingerprint all survive it. Treat it as changing your exit IP and nothing more.

Publishing a v3 onion service

Onion services give both parties anonymity and provide authenticated, end-to-end encrypted transport with no CA involved — the .onion address is the public key.

# /etc/tor/torrc
HiddenServiceDir /var/lib/tor/my_service/
HiddenServicePort 80 127.0.0.1:8080

# Enable the proof-of-work DoS defense (Tor 0.4.8+, 2023)
HiddenServicePoWDefensesEnabled 1
sudo systemctl reload tor
sudo cat /var/lib/tor/my_service/hostname   # -> <56-char>.onion

The directory now holds hs_ed25519_secret_key, hs_ed25519_public_key, and hostname. hs_ed25519_secret_key is the identity — anyone who copies it can impersonate the service permanently. Back it up encrypted; never commit it; mode 0600, owned by the tor user.

Restrict access to named clients (formerly "client authorization", now restricted discovery) by dropping public keys into authorized_clients/:

# /var/lib/tor/my_service/authorized_clients/alice.auth
descriptor:x25519:<BASE32_PUBKEY>

Bind the backend to loopback only (127.0.0.1:8080). An onion service whose origin is also reachable on a public IP is trivially correlated and defeats the entire construction — this is how several high-profile services were located.

Vanity addresses

Shallot and Scallion from the book only ever produced v2 addresses and are dead. The v3 tool is mkp224o:

git clone https://github.com/cathugger/mkp224o && cd mkp224o
./autogen.sh && ./configure && make
./mkp224o -d ./out amani     # addresses beginning "amani"

Difficulty is exponential in prefix length — 6 characters is quick, 8 is hours, and beyond that is a research budget. A vanity prefix is branding, not security: users must still verify the full 56-character address.


Key patterns

Don't blanket-block Tor exit nodes

The most common way a product harms at-risk users is invisible: a WAF rule or a fraud vendor silently blocking every Tor exit. That locks out journalists, abuse survivors, and people under censorship — the exact users who need you most.

// Tier by ACTION, not by network origin.
// Tor traffic is not fraud; it is traffic whose origin you cannot see.
export function riskTier(req: Request): "open" | "challenge" | "deny" {
  if (isReadOnly(req)) return "open";        // never block reading
  if (isAccountMutation(req)) return "challenge"; // proof-of-work / captcha
  return "deny";                              // only for known-abusive patterns
}

Rate-limit on a session or workload token, never on IP alone — IP-based limits punish everyone behind one exit node and are trivially evaded by anyone who matters.

Log hygiene — the leak that survives every other control

Most deanonymization risk in a normal SaaS lives in logs, not in the network.

/** Truncate IPs before they are ever written. IPv4 -> /24, IPv6 -> /48. */
export function coarseIp(ip: string): string {
  if (ip.includes(":")) return ip.split(":").slice(0, 3).join(":") + "::/48";
  return ip.split(".").slice(0, 3).join(".") + ".0/24";
}

const PII = /(\+?254\d{9})|([\w.+-]+@[\w-]+\.[\w.]+)|(\b[A-Z]{2}\d{6}\b)/g;
export const scrub = (s: string) => s.replace(PII, "[redacted]");

Apply it at the boundary — Sentry beforeSend, the logger transport, and analytics — so no code path can bypass it:

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  sendDefaultPii: false,
  beforeSend(event) {
    if (event.user) { delete event.user.ip_address; delete event.user.email; }
    return JSON.parse(scrub(JSON.stringify(event)));
  },
});

Timing and volume are identifiers

An "anonymous" report submitted at 14:03 from a clinic that has four staff is not anonymous. Where the anonymity set is small, add jitter and batching rather than delivering immediately:

// Release anonymous submissions on a fixed cadence so submission time
// carries no information about event time.
const BATCH_WINDOW_MS = 60 * 60 * 1000;
export const releaseAt = (t: number) =>
  Math.ceil(t / BATCH_WINDOW_MS) * BATCH_WINDOW_MS;

Strip file metadata on upload

Photos carry GPS coordinates, device serials, and timestamps. For any user-supplied image, re-encode server-side and drop all EXIF — never trust the client to have done it.

import sharp from "sharp";
// Re-encoding drops EXIF/GPS by default; `rotate()` first so orientation
// survives the metadata loss.
export const sanitize = (buf: Buffer) =>
  sharp(buf).rotate().toFormat("webp", { quality: 82 }).toBuffer();

Anonymous intake

For genuine whistleblower or abuse-report intake, do not roll your own. Use SecureDrop (onion-based, hardened, designed for newsrooms) or Hush Line for a lighter-weight tip line. A bespoke "anonymous form" on your main domain shares TLS fingerprints, CDN logs, and analytics with the rest of your product, and almost always leaks.


codeAmani notes

Security

African-market and Kenya-targeted projects

This is where the guide earns its keep, because our Kenya-targeted builds collect precisely the identifiers that deanonymize:

AI routing

Where this guide fits


Scope statement. This guide covers defensive privacy engineering, censorship circumvention, and protection of at-risk users — journalists, abuse survivors, whistleblowers, and people under repressive governments. It deliberately excludes operational guidance for evading lawful investigation, and does not modernize the source book's darknet-marketplace chapters.

Official docs:

Anthropic Integration Guide

Technology: anthropic · Category: ai · Last reviewed: 2026-08-30

Source: https://tech-stack.codeamanilabs.org/guide/anthropic

Insight:

Claude is codeAmani's primary model for reasoning and code generation. claude-opus-5 ($5/$25 per MTok) is the default — do not downgrade for cost without a measured reason. Step down to claude-sonnet-5 ($2/$10) for everyday volume and claude-haiku-4-5 ($1/$5) for latency-sensitive bulk work; step up to claude-fable-5 ($10/$50) for frontier long-running agents. Tune spend with output_config.effort plus adaptive thinking, not the removed budget_tokens. Call Claude through the Vercel AI SDK / AI Gateway (anthropic/... model strings) so provider swaps stay config, not code, and lean on prompt caching whenever a large system prompt or RAG context repeats across calls.

 █████╗ ███╗   ██╗████████╗██╗  ██╗██████╗  ██████╗ ██████╗ ██╗ ██████╗
██╔══██╗████╗  ██║╚══██╔══╝██║  ██║██╔══██╗██╔═══██╗██╔══██╗██║██╔════╝
███████║██╔██╗ ██║   ██║   ███████║██████╔╝██║   ██║██████╔╝██║██║
██╔══██║██║╚██╗██║   ██║   ██╔══██║██╔══██╗██║   ██║██╔═══╝ ██║██║
██║  ██║██║ ╚████║   ██║   ██║  ██║██║  ██║╚██████╔╝██║     ██║╚██████╗
╚═╝  ╚═╝╚═╝  ╚═══╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝ ╚═╝     ╚═╝ ╚═════╝

Anthropic Integration Guide

Focus: Using Anthropic's APIs, SDKs, and MCP tooling directly within Claude Code workflows and automation pipelines.

Overview

Anthropic is the company behind Claude and Claude Code itself. Integrating Anthropic's APIs into Claude Code lets you build AI-assisted workflows, automate code generation, chain Claude API calls inside hooks, and extend Claude Code with custom MCP servers — all using first-party tooling.

Here is the big picture of how these first-party pieces fit together — once you see the shape, everything below slots right in.

flowchart TD
  A["You · prompt or hook event"] --> B["Claude Code CLI"]
  B --> C["Anthropic SDK · messages.create"]
  C --> D["Claude API"]
  D --> E["Response · code, review, tests"]
  B --> F["MCP servers · custom tools"]
  F --> B
  B --> G["Automation · hooks and slash commands"]
  G --> C

Official Documentation

Resource URL
Claude API Docs https://platform.claude.com/docs/en/home
Claude Code Docs https://code.claude.com/docs
Models overview https://platform.claude.com/docs/en/models/overview
Managed Agents https://platform.claude.com/docs/en/managed-agents/quickstart
CLI, SDKs & libraries https://platform.claude.com/docs/en/cli-sdks-libraries/overview
Release notes https://platform.claude.com/docs/en/release-notes/overview
Help Center (accounts, billing, plans) https://support.claude.com/en
Model Context Protocol https://modelcontextprotocol.io
API Reference https://platform.claude.com/docs/en/api/overview
MCP SDK (TypeScript) https://github.com/modelcontextprotocol/typescript-sdk
MCP SDK (Python) https://github.com/modelcontextprotocol/python-sdk

Docs domain moved. Anthropic's developer docs now live at platform.claude.com/docs (the old docs.anthropic.com/... URLs 301-redirect there); the console is at platform.claude.com, status at status.claude.com, and pricing at claude.com/pricing. Claude Code docs stay at code.claude.com/docs.


MCP Server Setup

Claude Code as an MCP Server

Claude Code itself can act as an MCP server, exposing its tools to other clients.

# Start Claude Code as an MCP server (stdio transport)
claude mcp serve

Building a Custom MCP Server with @anthropic-ai/mcpb

@anthropic-ai/mcpb is Anthropic's official MCP bundle tool for creating distributable local MCP servers.

npm install -g @anthropic-ai/mcpb

Create a new MCP bundle project:

mcpb init my-server
cd my-server
mcpb build
mcpb install   # installs the bundle into Claude Code

Connecting to the Official Claude Code MCP Server

# Add Claude Code as an MCP server inside another MCP client
claude mcp add claude-code -- claude mcp serve

.mcp.json Configuration

Create .mcp.json in your project root to auto-connect MCP servers when Claude Code opens:

{
  "mcpServers": {
    "anthropic-code": {
      "command": "claude",
      "args": ["mcp", "serve"],
      "env": {}
    }
  }
}

Claude Code CLI Integration

Installation

npm install -g @anthropic-ai/claude-code

Key Commands

# Start interactive session
claude

# Run a one-shot prompt (non-interactive)
claude -p "Explain the auth flow in src/auth.ts"

# Run with a specific model
claude --model claude-opus-5

# Continue the most recent session
claude --continue

# Run a bash command within a Claude session
claude -p "Fix the TypeScript errors" --allowedTools Bash,Edit,Write

# Start as MCP server
claude mcp serve

# Manage MCP servers
claude mcp add <name> -- <command> [args]
claude mcp list
claude mcp remove <name>

# Add remote MCP server (HTTP transport)
claude mcp add --transport http my-server https://my-server.example.com/mcp

Anthropic SDK Integration

A single messages.create call is the heartbeat of every SDK example below — here is exactly what happens on each request.

sequenceDiagram
  participant App as "Your app or script"
  participant SDK as "Anthropic SDK"
  participant API as "Claude API"
  App->>SDK: "messages.create · model, max_tokens, messages"
  SDK->>API: "authenticated request · ANTHROPIC_API_KEY"
  API-->>SDK: "message · content blocks"
  SDK-->>App: "message.content·0·.text"

Node.js / TypeScript

npm install @anthropic-ai/sdk
import Anthropic from "@anthropic-ai/sdk";

// Zero-arg constructor resolves ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
// or an `ant auth login` profile — see Authentication below.
const client = new Anthropic();

const message = await client.messages.create({
  model: "claude-opus-5",
  max_tokens: 16000,
  messages: [{ role: "user", content: "Review this code for security issues." }],
});

// `content` is a discriminated union — narrow by `.type` before reading `.text`.
for (const block of message.content) {
  if (block.type === "text") console.log(block.text);
}

The TypeScript SDK is at @anthropic-ai/sdk 0.122.0. The messages.create shape above is stable across the 0.x line.

Python

pip install anthropic
import anthropic

# Resolves ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or an `ant auth login` profile.
client = anthropic.Anthropic()

message = client.messages.create(
    model="claude-opus-5",
    max_tokens=16000,
    messages=[{"role": "user", "content": "Generate unit tests for this function."}],
)

# Narrow by block type — tool_use blocks have no .text attribute.
for block in message.content:
    if block.type == "text":
        print(block.text)

The Python SDK is at anthropic 1.2.0. The 0.x → 1.x breaking change was the upgrade to httpx 2 (see the SDK's MIGRATION.md); client.messages.create(...) usage is unchanged. Pin anthropic>=1,<2 and make sure your environment allows httpx 2.


Thinking, Effort, and the Claude 5 Request Shape

The Claude 5 family changed the request surface in ways that silently break code written for the 4.x line. Three things are now rejected with a 400 on claude-fable-5, claude-opus-5, and claude-sonnet-5:

Removed Replacement
thinking: { type: "enabled", budget_tokens: N } thinking: { type: "adaptive" }
temperature / top_p / top_k none — sampling is model-managed
Assistant-message prefill (pre-filling the last turn) output_config.format, or a system instruction

Adaptive thinking lets Claude decide when and how deeply to reason instead of you pre-buying a token budget. On claude-opus-5 thinking is on by default — omitting the parameter runs adaptive. Effort is the dial that replaced budget_tokens, and it lives inside output_config, not at the top level:

const message = await client.messages.create({
  model: "claude-opus-5",
  max_tokens: 16000,
  thinking: { type: "adaptive", display: "summarized" },
  output_config: { effort: "xhigh" }, // low | medium | high | xhigh | max
  messages: [{ role: "user", content: "Refactor this module and explain why." }],
});

max_tokens is a truncation cliff, not a cost control. Default to 16000 for non-streaming calls and 64000 when streaming. Claude 5 models support up to 128K output tokens, but the SDKs require streaming at that size to avoid HTTP timeouts. The old 1024 habit truncates mid-thought and buys you a retry.


Structured Outputs

When you need JSON that actually validates, constrain the response instead of parsing hopefully. Use output_config.format — the older top-level output_format parameter is deprecated:

const message = await client.messages.create({
  model: "claude-opus-5",
  max_tokens: 16000,
  output_config: {
    format: {
      type: "json_schema",
      schema: {
        type: "object",
        properties: {
          severity: { type: "string", enum: ["low", "medium", "high"] },
          summary: { type: "string" },
        },
        required: ["severity", "summary"],
        additionalProperties: false,
      },
    },
  },
  messages: [{ role: "user", content: "Triage this Sentry error: " + stackTrace }],
});

For tool arguments, set strict: true as a top-level field on the tool definition (not on tool_choice); the schema needs additionalProperties: false plus required. Structured outputs are incompatible with document citations — sending both returns a 400.


Managed Agents

The docs home now leads with two developer surfaces: the Messages API (you own the loop) and Managed Agents (Anthropic runs the loop and hosts the sandbox where tools execute). Reach for Managed Agents when the alternative is writing your own scheduler, session store, and container runtime.

The flow is agent once → session per run. model, system, and tools live on the agent, never on the session:

// 1. Create the agent once. Store the ID — never call this in the request path.
const agent = await client.beta.agents.create({
  model: "claude-opus-5",
  system: "You reconcile M-Pesa settlement files against Stripe payouts.",
  tools: [{ type: "bash_20250124", name: "bash" }],
});

// 2. Start a session per run, referencing the stored agent ID.
const session = await client.beta.sessions.create({ agent_id: agent.id });

Three different things that sound alike. Agent Skills generate .pptx/.xlsx via container.skills on a normal messages.create. The Claude Agent SDK (@anthropic-ai/claude-agent-sdk) is Claude Code packaged as a library that you host. Only Managed Agents supplies both a managed harness and managed deployment.


Prompt Caching

When you reuse the same large block across calls — a frozen system prompt, a long tool set, retrieved RAG context — mark it with cache_control: { type: "ephemeral" }. Anthropic caches that prefix and serves it back at roughly 0.1× input cost on cache hits, with lower latency. For codeAmani's AI features (review bots, support agents, Swahili/English assistants) this is the single biggest cost lever when the per-request question is small but the shared context is huge.

The one rule: caching is a prefix match. Render order is tools → system → messages, and any byte change before a breakpoint invalidates everything after it. Keep stable content first; put volatile content (the user's question, a timestamp, a per-request ID) after the last breakpoint.

Two ways to cache. Automatic caching — set one cache_control: { type: "ephemeral" } at the top level of the request and Anthropic manages the breakpoint, moving it forward to the last cacheable block as a conversation grows (ideal for multi-turn chat/agents). Explicit breakpoints — the block-level markers shown below, for fine-grained control over exactly what caches. Automatic caching consumes one of your 4 breakpoint slots. The explicit form is used in this example because codeAmani's hot paths reuse a fixed tool set + system prompt.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const message = await client.messages.create({
  model: "claude-opus-5",
  max_tokens: 16000,
  // Cache the tool set — tools render at position 0, so this prefix is reused first.
  tools: [
    {
      name: "search_orders",
      description: "Look up M-Pesa orders by phone number.",
      input_schema: {
        type: "object",
        properties: { phone: { type: "string" } },
        required: ["phone"],
      },
      cache_control: { type: "ephemeral" },
    },
  ],
  // Cache the large, frozen system prompt — breakpoint on the LAST block caches tools + system together.
  system: [
    {
      type: "text",
      text: LARGE_SHARED_PROMPT, // e.g. product catalog, brand rules, RAG context
      cache_control: { type: "ephemeral" }, // add `ttl: "1h"` for bursty traffic with idle gaps
    },
  ],
  // Volatile content goes last, after the cached prefix — no marker here.
  messages: [{ role: "user", content: "Where is my last order?" }],
});

// Confirm it worked — cache_read_input_tokens should be > 0 on the 2nd+ identical-prefix call.
console.log(message.usage.cache_read_input_tokens, message.usage.cache_creation_input_tokens);

Notes that bite in practice:

Via the AI Gateway: when calling Claude through the Vercel AI SDK with anthropic/... model strings (codeAmani's default — see the insight), pass cache_control through providerOptions.anthropic so the marker reaches the underlying API. Caching is an Anthropic-side feature; the gateway forwards it but does not invent it.

flowchart LR
  A["Request · tools then system then messages"] --> B{"Prefix byte-identical<br/>to a cached entry"}
  B -->|"yes · cache hit"| C["Served at 0.1× input cost<br/>cache_read_input_tokens > 0"]
  B -->|"no · cache miss"| D["Full price · writes cache<br/>at 1.25× then reusable"]
  D --> E["Next call reuses the prefix"]
  E --> B

Canonical reference: https://platform.claude.com/docs/en/build-with-claude/prompt-caching


Authentication and Environment Variables

An unset ANTHROPIC_API_KEY does not mean you have no credentials. The SDKs and the ant CLI resolve in this order, first match wins:

  1. ANTHROPIC_API_KEY
  2. ANTHROPIC_AUTH_TOKEN
  3. The ANTHROPIC_PROFILE-selected (or active) OAuth profile from ant auth login
  4. Workload Identity Federation environment variables
  5. The default profile on disk

A bare new Anthropic() therefore works after ant auth login with no env var set. Check which source is actually live before concluding a key is missing:

ant auth status   # shows the active credential source and profile
ant auth login    # stores a profile under ~/.config/anthropic/ that the SDKs read
# Server-side only — never ship this into a client bundle
ANTHROPIC_API_KEY=sk-ant-...

# Optional overrides
ANTHROPIC_BASE_URL=https://api.anthropic.com   # default
ANTHROPIC_MODEL=claude-opus-5                  # default model for the claude CLI
ANTHROPIC_PROFILE=work                         # select a named `ant auth login` profile

# Claude Code specific
CLAUDE_CODE_MAX_OUTPUT_TOKENS=32000

Set these in your shell profile, in .env.local for local dev, or in the Vercel dashboard for production.

Raw curl under OAuth: an ant profile is not an API key. Mint a short-lived token with ant auth print-credentials --access-token, then send it as Authorization: Bearer <token> plus the header anthropic-beta: oauth-2025-04-20. OAuth tokens do not go in x-api-key — converting a working curl from an API key is a header change, not just a value swap.


Automation Workflows

Claude Code Hooks

Hooks run shell commands automatically at lifecycle events. Configure in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Tool: Bash about to run' >> .claude/audit.log"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write $CLAUDE_FILE_PATH 2>/dev/null || true"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/notify-complete.js"
          }
        ]
      }
    ]
  }
}

Slash Commands

Create custom slash commands as markdown files in .claude/commands/:

mkdir -p .claude/commands

.claude/commands/review.md:

Review the following code for: security issues, performance problems, and code quality.
Focus on: $ARGUMENTS
Provide actionable fixes with code examples.

Usage inside Claude Code: /project:review src/api/auth.ts

Headless Automation with the SDK

Use the Claude API to automate code review in CI:

// scripts/ai-review.ts
import Anthropic from "@anthropic-ai/sdk";
import { readFileSync } from "fs";

const client = new Anthropic();
const diff = readFileSync("latest.diff", "utf-8");

const review = await client.messages.create({
  model: "claude-opus-5",
  max_tokens: 16000,
  output_config: { effort: "xhigh" },
  system: "You are a senior code reviewer. Be concise and actionable.",
  messages: [{ role: "user", content: `Review this diff:\n\n${diff}` }],
});

for (const block of review.content) {
  if (block.type === "text") console.log(block.text);
}

Common Use Cases

Use Case Approach
Automated PR review Fetch diff via gh, pipe to Claude API
Code generation claude -p "Generate CRUD endpoints for User model"
Test generation Hook on PostToolUse[Write] to auto-generate tests
Documentation claude -p "Document all exported functions in src/"
Security scanning Combine with Semgrep output piped to Claude API
Refactoring Use --continue sessions for multi-step refactors

CLAUDE.md Configuration

Create CLAUDE.md at your project root to give Claude Code persistent context:

# Project Context

## Tech Stack
- TypeScript, Node.js 22, PostgreSQL
- Test runner: Vitest
- Linter: ESLint + Prettier

## Conventions
- Use `async/await` — no raw Promises
- All functions must have JSDoc comments
- Tests go in `__tests__/` next to source files

## Forbidden
- Never use `any` type
- Never commit `.env` files

Troubleshooting

Issue Fix
ANTHROPIC_API_KEY not found Export it in shell: export ANTHROPIC_API_KEY=sk-ant-...
Rate limit errors Add retry logic with exponential backoff
MCP server not connecting Run claude mcp list to verify registration
Hooks not firing Check .claude/settings.json syntax with cat .claude/settings.json | jq .
Model not available Check the live list at platform.claude.com/docs/en/models/overview
400 on budget_tokens Removed on Claude 5 — use thinking: { type: "adaptive" } + output_config.effort
400 on temperature or a prefilled assistant turn Both removed on Claude 5 — shape output with output_config.format
Reasoning UI shows a long blank pause display defaults to "omitted" — set thinking.display: "summarized"

Official docs:

Auth0 Integration Guide

Technology: auth0 · Category: auth · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/auth0

Insight:

Auth0 is advertised on motionstackstudios.com, but it is not a house default — the dashboard ships custom Argon2 + session auth, and Clerk is the managed-auth default. Reach for Auth0 only when a client specifically requires it: enterprise SSO/SAML, B2B Organizations, or an existing Auth0 tenant. Before adopting it, consult the codeAmani-tech-stack MCP and confirm the house defaults won't do.

 █████╗ ██╗   ██╗████████╗██╗  ██╗ ██████╗
██╔══██╗██║   ██║╚══██╔══╝██║  ██║██╔═████╗
███████║██║   ██║   ██║   ███████║██║██╔██║
██╔══██║██║   ██║   ██║   ██╔══██║████╔╝██║
██║  ██║╚██████╔╝   ██║   ██║  ██║╚██████╔╝
╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚═╝  ╚═╝ ╚═════╝

Auth0 Integration Guide

Focus: When and how codeAmani uses Auth0 for client projects that specifically need it — enterprise SSO/SAML, B2B Organizations, or an inherited Auth0 tenant. Covers the @auth0/nextjs-auth0 v4 App Router SDK (Universal Login, middleware, auth0.getSession()), RBAC, the Management API via the auth0 node SDK, and JWT verification for API routes.

Overview

Auth0 (an Okta company) is an identity platform built on OAuth 2.0 / OIDC: Universal Login hosts the sign-in page, the app receives an authorization code at a callback, exchanges it for an encrypted session cookie, and from then on reads the user from that session. It does social and enterprise connections, RBAC with permissions, post-login Actions, a Management API for programmatic user/role administration, and Organizations for B2B multi-tenancy.

Auth0 is listed as an auth option on motionstackstudios.com, but it is not a house default. The agency's own dashboard runs custom Argon2id password hashing + server-side sessions, and Clerk (see the clerk guide) is the managed-auth default for most builds — drop-in components, Svix-verified webhooks, simpler pricing. Auth0 only earns a place when a client's contract requires it:

flowchart TD
    A["New client build<br/>needs authentication"] --> B{"Hard requirement for Auth0?<br/>(enterprise SSO/SAML,<br/>B2B orgs, existing tenant)"}
    B -->|No| C["House defaults<br/>custom Argon2 + sessions · Clerk"]
    B -->|Yes| D["Auth0 tenant<br/>(per-client, isolated)"]
    D --> E["@auth0/nextjs-auth0 v4<br/>Universal Login · middleware"]
    E --> F["RBAC · Actions · Organizations"]
    F --> G["Management API (auth0 SDK)<br/>JWT verify for APIs"]

Check first. Before adopting Auth0, query the codeAmani-tech-stack MCP (search_guides / get_guide) and confirm a cheaper option won't satisfy the requirement: Clerk (the managed default), the dashboard's custom Argon2 + sessions, or self-hosted Better Auth (see the better-auth guide — no per-MAU bill, a JOIN-able user table you own). Auth0 adds a vendor, per-MAU cost, and an extra tenant to operate — prefer one of those unless the client specifically requires Auth0's enterprise SSO/SAML or B2B Organizations.

The OAuth/OIDC login flow the SDK wires up:

sequenceDiagram
  participant U as "User"
  participant App as "Next.js app"
  participant MW as "auth0.middleware"
  participant A0 as "Auth0 (Universal Login)"
  U->>App: "Visit /dashboard (protected)"
  App->>MW: "Request hits middleware"
  MW->>A0: "No session → redirect to /authorize"
  A0->>U: "Universal Login (social / DB / enterprise)"
  U->>A0: "Authenticate (+ MFA if enforced)"
  A0->>App: "Redirect to /auth/callback?code=..."
  App->>A0: "Exchange code for ID + access tokens"
  A0-->>App: "Tokens → set encrypted session cookie"
  App-->>U: "Render /dashboard (auth0.getSession())"

Official Documentation

Resource URL
Auth0 Docs (root) https://auth0.com/docs
Next.js SDK (@auth0/nextjs-auth0 v4) https://github.com/auth0/nextjs-auth0
Management API v2 https://auth0.com/docs/api/management/v2
Actions (post-login triggers) https://auth0.com/docs/customize/actions
RBAC (roles & permissions) https://auth0.com/docs/manage-users/access-control/rbac
Organizations (B2B) https://auth0.com/docs/manage-users/organizations

No first-party Auth0 MCP server is in the house stack. Drive the SDK from Claude Code with the patterns below; when unsure about a current API, query Context7 (resolve-library-id "Auth0 Next.js SDK" → /auth0/nextjs-auth0) rather than recalling v3 patterns — v4 changed the entire surface.


Next.js App Router Setup (@auth0/nextjs-auth0 v4)

The v4 SDK (current: 4.27.0) is a clean break from v3. There is no handleAuth() catch-all route and no UserProvider import path you remember — instead you instantiate a single Auth0Client, mount it in middleware, and the SDK auto-serves /auth/login, /auth/logout, /auth/callback, /auth/profile, /auth/access-token, and /auth/backchannel-logout.

npm install @auth0/nextjs-auth0   # v4.27.0 at last review

1. The Auth0 client

lib/auth0.ts:

import { Auth0Client } from "@auth0/nextjs-auth0/server";

// Reads AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET,
// and APP_BASE_URL from the environment automatically.
export const auth0 = new Auth0Client({
  authorizationParameters: {
    scope: "openid profile email offline_access",
    // Set an audience to receive a JWT access token for your own API.
    audience: process.env.AUTH0_AUDIENCE,
  },
});

2. Middleware mounts the routes

middleware.ts (project root):

import type { NextRequest } from "next/server";
import { auth0 } from "@/lib/auth0";

export async function middleware(request: NextRequest) {
  // Serves /auth/login, /auth/logout, /auth/callback and refreshes the session.
  return await auth0.middleware(request);
}

export const config = {
  matcher: [
    // Run on everything except static assets and metadata files.
    "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

3. The login / logout UI

The SDK exposes the auth actions as plain links — no client component required.

app/layout.tsx:

import { auth0 } from "@/lib/auth0";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth0.getSession();

  return (
    <html lang="en">
      <body>
        <header style={{ display: "flex", justifyContent: "flex-end", gap: 12, padding: 16 }}>
          {session ? (
            <a href="/auth/logout">Log out ({session.user.name})</a>
          ) : (
            <a href="/auth/login">Log in</a>
          )}
        </header>
        {children}
      </body>
    </html>
  );
}

Gotcha: In v4 the login route is /auth/login, not /api/auth/login (that was v3). To send the user somewhere specific after login, use /auth/login?returnTo=/dashboard. If you rename routes via the routes option on Auth0Client, update these links to match.


Protecting Routes

There are two layers: the middleware refreshes/attaches the session, and each protected page or API route reads it.

Server Components

app/dashboard/page.tsx:

import { redirect } from "next/navigation";
import { auth0 } from "@/lib/auth0";

export default async function DashboardPage() {
  const session = await auth0.getSession();

  if (!session) {
    // Bounce through Universal Login, then return here.
    redirect("/auth/login?returnTo=/dashboard");
  }

  return <h1>Welcome, {session.user.name}</h1>;
}

Or wrap with the helper (note returnTo is required in the App Router — Server Components don't know their own URL):

import { auth0 } from "@/lib/auth0";

export default auth0.withPageAuthRequired(
  async function Profile() {
    const { user } = await auth0.getSession();
    return <div>Hello {user.name}</div>;
  },
  { returnTo: "/profile" }
);

API Route Handlers

app/api/me/route.ts:

import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";

export async function GET() {
  const session = await auth0.getSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  return NextResponse.json({ user: session.user });
}

RBAC (Roles & Permissions)

Enable RBAC on the API in the Auth0 dashboard (APIs → your API → RBAC Settings → "Enable RBAC" + "Add Permissions in the Access Token"). Then assign roles to users; the role's permissions land in the access token's permissions claim.

Roles themselves don't appear in the ID token by default — surface them with a post-login Action under a namespaced custom claim (Auth0 silently drops non-namespaced claims):

// Auth0 Dashboard → Actions → Library → Post Login
exports.onExecutePostLogin = async (event, api) => {
  const namespace = "https://motionstack.app";
  const roles = event.authorization?.roles ?? [];

  api.idToken.setCustomClaim(`${namespace}/roles`, roles);
  api.accessToken.setCustomClaim(`${namespace}/roles`, roles);
};

Read roles from the session, and permissions by decoding the access token:

// lib/rbac.ts
import { auth0 } from "@/lib/auth0";

const NS = "https://motionstack.app";

export async function getRoles(): Promise<string[]> {
  const session = await auth0.getSession();
  return (session?.user?.[`${NS}/roles`] as string[]) ?? [];
}

export async function requireRole(role: string): Promise<void> {
  const roles = await getRoles();
  if (!roles.includes(role)) {
    throw new Response("Forbidden", { status: 403 });
  }
}

Gotcha: Custom claims must be fully-qualified URLs (a namespace you control). A bare roles claim is stripped by Auth0 and will silently never appear in the token.


Management API (the auth0 node SDK)

For server-side user/role administration — listing users, assigning roles, updating metadata — use the auth0 node SDK's ManagementClient. Create a Machine-to-Machine application in the dashboard, authorize it for the Auth0 Management API, and grant the specific scopes (read:users, update:users, create:role_members, …). The SDK fetches and caches its own token via client credentials.

npm install auth0   # v6.x (SDK rewritten in v5 — see the callout below)
// lib/auth0-management.ts
import { ManagementClient } from "auth0";

export const management = new ManagementClient({
  domain: process.env.AUTH0_DOMAIN!,           // e.g. acme.us.auth0.com (no scheme)
  clientId: process.env.AUTH0_M2M_CLIENT_ID!,
  clientSecret: process.env.AUTH0_M2M_CLIENT_SECRET!,
});

/** Assign a role to a user (e.g. after a Stripe upgrade webhook). */
export async function grantRole(userId: string, roleId: string): Promise<void> {
  await management.users.roles.assign(userId, { roles: [roleId] });
}

/** Persist app-level state on the Auth0 user record. */
export async function setPlan(userId: string, plan: string): Promise<void> {
  await management.users.update(userId, { app_metadata: { plan } });
}

/** Look up a user by email (admin tooling). */
export async function findByEmail(email: string) {
  // v5+ returns the array directly — there is no `.data` wrapper anymore.
  return await management.users.listUsersByEmail({ email });
}

v5+ rewrite (breaking). The auth0 node SDK was regenerated in v5 (Sep 2025; v6.x current). Method arguments are now positional — users.update(userId, body), not the v4 users.update({ id }, body) — sub-resources moved under sub-clients (users.roles.assign(...), users.listUsersByEmail(...)), and responses no longer wrap in { data } (call .withRawResponse() if you need headers/status). The ManagementClient constructor itself is unchanged. Any snippet using assignRoles(...), usersByEmail.getByEmail(...), or { id }-wrapped args is pre-v5 and will not compile — regenerate it from the current reference.

Gotcha: The Management API is heavily rate-limited (and the M2M client may bill per token). Never call it on a hot request path — only from webhooks, admin actions, and background jobs. Use app_metadata (server-controlled) for authorization-relevant fields and user_metadata (user-editable) for preferences.


JWT Verification for APIs

When a separate service (a mobile app, a backend microservice, a third party) calls your API with a Bearer token issued by Auth0, verify the JWT against Auth0's published JWKS — check the signature, issuer, audience, and expiry. Use jsonwebtoken with jwks-rsa to fetch and cache the signing keys.

npm install jsonwebtoken jwks-rsa
// lib/verify-jwt.ts
import jwt, { type JwtPayload } from "jsonwebtoken";
import { JwksClient } from "jwks-rsa";

const issuer = `https://${process.env.AUTH0_DOMAIN}/`;

const jwks = new JwksClient({
  jwksUri: `${issuer}.well-known/jwks.json`,
  cache: true,
  rateLimit: true,
});

function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key!.getPublicKey());
  });
}

/** Verify a bearer token (RS256) and return its claims. */
export function verifyAccessToken(token: string): Promise<JwtPayload> {
  return new Promise((resolve, reject) => {
    jwt.verify(
      token,
      getKey,
      {
        algorithms: ["RS256"],
        issuer,
        audience: process.env.AUTH0_AUDIENCE,
      },
      (err, decoded) => (err ? reject(err) : resolve(decoded as JwtPayload)),
    );
  });
}

Use it to guard a machine-facing route:

// app/api/v1/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyAccessToken } from "@/lib/verify-jwt";

export async function GET(req: NextRequest) {
  const auth = req.headers.get("authorization");
  if (!auth?.startsWith("Bearer ")) {
    return NextResponse.json({ error: "Missing bearer token" }, { status: 401 });
  }

  try {
    const claims = await verifyAccessToken(auth.slice(7));
    const scopes = (claims.scope as string | undefined)?.split(" ") ?? [];
    if (!scopes.includes("read:orders")) {
      return NextResponse.json({ error: "Insufficient scope" }, { status: 403 });
    }
    return NextResponse.json({ sub: claims.sub, orders: [] });
  } catch {
    return NextResponse.json({ error: "Invalid token" }, { status: 401 });
  }
}

Note: This is for first-party browser sessions handled by the SDK plus separate API callers. For the browser app itself, auth0.getSession() is the path — don't re-verify the SDK's own session cookie by hand.


Calling Your Own API From the App

When the app needs to call a downstream API with a real Auth0-issued access token, request it with auth0.getAccessToken() (the SDK handles refresh via offline_access):

// app/api/data/route.ts
import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";

export async function GET() {
  const { token } = await auth0.getAccessToken();

  const res = await fetch("https://data-api.example.com/records", {
    headers: { Authorization: `Bearer ${token}` },
  });

  return NextResponse.json(await res.json());
}

Organizations (B2B Multi-Tenancy)

For B2B clients, Organizations model each customer company as a tenant with its own members, roles, and (critically) its own enterprise connection — so Acme logs in via their Okta SAML and Globex via their Azure AD, all in one Auth0 tenant. Enable "Organizations" on the application, then route users through an org-scoped login:

// Send a user to log in within a specific organization.
// app/teams/[orgId]/login/route.ts
import { redirect } from "next/navigation";

export async function GET(_: Request, { params }: { params: { orgId: string } }) {
  redirect(`/auth/login?organization=${params.orgId}&returnTo=/teams/${params.orgId}`);
}

The active organization lands in the session as the org_id claim — gate org-scoped data on it. This is the primary reason a codeAmani client picks Auth0 over Clerk: per-organization SAML/OIDC enterprise connections out of the box.


Environment Variables

# Core SDK config (all required by @auth0/nextjs-auth0 v4)
AUTH0_DOMAIN=acme.us.auth0.com          # tenant domain — NO https:// scheme
AUTH0_CLIENT_ID=...                     # the Regular Web App client
AUTH0_CLIENT_SECRET=...                 # server-side only — NEVER ship to the bundle
AUTH0_SECRET=...                        # 32-byte hex for cookie encryption: `openssl rand -hex 32`
APP_BASE_URL=http://localhost:3000      # your app's base URL (prod: https://app.example.com)

# Optional: audience for a JWT access token to your own API
AUTH0_AUDIENCE=https://api.example.com

# Management API (separate Machine-to-Machine application)
AUTH0_M2M_CLIENT_ID=...
AUTH0_M2M_CLIENT_SECRET=...             # server-side only

Add these to ENV_MASTER.md and each project's .env.example. AUTH0_SECRET encrypts the session cookie — rotate it and every session is invalidated, so treat it like a signing key. AUTH0_DOMAIN must be the bare host (the v3 AUTH0_ISSUER_BASE_URL with a scheme is gone); AUTH0_BASE_URL was renamed to APP_BASE_URL in v4.


Automation Workflows

Claude Code slash command: scaffold Auth0 auth

.claude/commands/auth0-setup.md:

Scaffold @auth0/nextjs-auth0 v4 authentication for this Next.js App Router project.

First confirm Auth0 is actually required (enterprise SSO/SAML, B2B orgs, or an
existing tenant) — if not, recommend Clerk per the house default and stop. Then:

1. Install `@auth0/nextjs-auth0` if not already in package.json.
2. Create `lib/auth0.ts` exporting a configured `Auth0Client`.
3. Create `middleware.ts` calling `auth0.middleware(request)` with the asset matcher.
4. Add log-in / log-out links to `app/layout.tsx` (`/auth/login`, `/auth/logout`).
5. Add a protected `app/dashboard/page.tsx` using `auth0.getSession()`.
6. Add all five required env vars to `.env.local` and `.env.example`
   (AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET, APP_BASE_URL).
7. Report manual dashboard steps: create the app, set Allowed Callback URLs to
   `${APP_BASE_URL}/auth/callback` and Allowed Logout URLs to `${APP_BASE_URL}`.

Usage: /project:auth0-setup

GitHub Actions: verify Auth0 config

# .github/workflows/auth0-verify.yml
name: Verify Auth0 Config
on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - name: Check middleware exists
        run: test -f middleware.ts || (echo "Missing middleware.ts!" && exit 1)
      - name: Check required env vars documented
        run: |
          for v in AUTH0_DOMAIN AUTH0_CLIENT_ID AUTH0_CLIENT_SECRET AUTH0_SECRET APP_BASE_URL; do
            grep -q "$v" .env.example || echo "Warning: $v missing from .env.example"
          done

Common Use Cases

Use Case Approach
Default managed auth Clerk (house default) — use Auth0 only when a client requires it
Default in-app auth Custom Argon2 + sessions (the dashboard) — Auth0 doesn't replace it
Add Auth0 to Next.js /project:auth0-setup slash command (v4 SDK)
Read the current user auth0.getSession() in a Server Component / Route Handler
Protect a page auth0.withPageAuthRequired(fn, { returnTo }) or a getSession() guard
Social / enterprise login Configure connections in dashboard → Universal Login picks them up
RBAC / permissions Enable RBAC on the API + namespaced-claim post-login Action
Admin user/role management Management API via the auth0 node SDK (ManagementClient)
Verify a machine-issued JWT jsonwebtoken + jwks-rsa against the tenant JWKS
Call your own API auth0.getAccessToken() with an audience configured
B2B SSO / per-tenant SAML Auth0 Organizations + enterprise connections
MFA enforcement Auth0 dashboard → Security → Multi-factor Auth (or a post-login Action)

Troubleshooting

Issue Fix
404 on /api/auth/login v4 uses /auth/login (no /api); update links — this is the #1 v3→v4 break
callback URL mismatch Add ${APP_BASE_URL}/auth/callback to the app's Allowed Callback URLs in the dashboard
Session not set after login Ensure auth0.middleware(request) runs and the matcher isn't excluding /auth/*
AUTH0_DOMAIN errors / invalid issuer Use the bare host (acme.us.auth0.com), no https:// scheme — that was v3's ISSUER_BASE_URL
All users logged out unexpectedly AUTH0_SECRET changed (or differs across instances) — it must be stable and identical everywhere
Roles missing from token Add a post-login Action setting a namespaced custom claim; bare roles is stripped
permissions claim empty Enable RBAC + "Add Permissions in the Access Token" on the API, and set an audience
jwt malformed / invalid signature Confirm RS256, the JWKS URI matches https://${AUTH0_DOMAIN}/.well-known/jwks.json, and audience is correct
Management API 429 You're calling it on a hot path — move to webhooks/jobs; the API is rate-limited
Management API 403 insufficient_scope Grant the specific scope (e.g. update:users) to the M2M app on the Management API

Official docs:

AWS Integration Guide

Technology: aws · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/aws

Insight:

AWS is the Enterprise-tier host — reserved for $50K+ client builds (incl. the HIPAA-compliant healthcare platform) where Neon/R2/Resend/Vercel can't satisfy a client's compliance, residency, or scale requirement. It complements, never replaces, the house defaults. Before reaching for AWS, consult the codeAmani-tech-stack MCP and confirm a default won't do; when you do use it, sign a BAA via AWS Artifact and stay on HIPAA-eligible services only.

 █████╗ ██╗    ██╗███████╗
██╔══██╗██║    ██║██╔════╝
███████║██║ █╗ ██║███████╗
██╔══██║██║███╗██║╚════██║
██║  ██║╚███╔███╔╝███████║
╚═╝  ╚═╝ ╚══╝╚══╝ ╚══════╝

AWS Integration Guide

Focus: When and how codeAmani uses Amazon Web Services for Enterprise-tier client builds — IAM least-privilege, S3/presigned URLs, RDS & Aurora Serverless v2, Lambda/API Gateway, ECS Fargate, CloudFront, SES, IaC with the AWS CDK (TypeScript), and a HIPAA baseline for the Florida healthcare niche.

Overview

AWS is advertised as infrastructure on motionstackstudios.com and is the host for the agency's Enterprise segment — $50K+ engagements, including a HIPAA-compliant healthcare platform for an APD/AHCA/DCF-licensed provider. It is the heavyweight option in a roster of house hosting guides (vercel, netlify, cloudflare, render, google-cloud), and it complements rather than replaces them.

The house defaults stay the same for almost everything: Neon for Postgres, Cloudflare R2 for object storage, Resend for email, Vercel for app hosting. AWS earns a place only when an enterprise client's contract demands something a default can't give — a signed BAA, data residency in a specific region, a private VPC, VPC-peered databases, or scale/SLA guarantees. The decision flow:

flowchart TD
    A["New enterprise build<br/>$50K+ engagement"] --> B{"Compliance / residency /<br/>scale requirement?"}
    B -->|No| C["House defaults<br/>Neon · R2 · Resend · Vercel"]
    B -->|Yes — e.g. HIPAA BAA| D["AWS account<br/>(per-client, isolated)"]
    D --> E["IAM least-privilege<br/>roles · no root keys"]
    E --> F["S3 + RDS/Aurora<br/>Lambda · ECS · CloudFront · SES"]
    F --> G["BAA via AWS Artifact<br/>HIPAA-eligible services only"]
    G --> H["Provisioned via CDK / Terraform<br/>reviewed by [CA] Cloud Architect"]

Check first. Before adding any AWS service, query the codeAmani-tech-stack MCP (search_guides / get_guide) and confirm a house default won't satisfy the requirement. AWS adds operational surface, cost, and compliance obligations — prefer the default unless the client specifically requires AWS.

Official Documentation

Resource URL
AWS Documentation (root) https://docs.aws.amazon.com
AWS SDK for JavaScript v3 https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/
AWS CDK v2 Developer Guide https://docs.aws.amazon.com/cdk/v2/guide/home.html
AWS CLI v2 User Guide https://docs.aws.amazon.com/cli/latest/userguide/
Lambda Node.js runtimes https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html
Official AWS MCP servers (awslabs/mcp) https://github.com/awslabs/mcp
HIPAA-Eligible Services Reference https://aws.amazon.com/compliance/hipaa-eligible-services-reference/
Architecting HIPAA on AWS (whitepaper) https://docs.aws.amazon.com/whitepapers/latest/architecting-hipaa-security-and-compliance-on-aws/

AWS now ships official MCP servers, but none is wired into the house .mcp.json by default. AWS publishes the open-source awslabs/mcp collection plus AWS-managed remote endpoints — the read-only AWS Knowledge MCP Server (https://knowledge-mcp.global.api.aws, no auth) for live AWS docs and API references, and the AWS API MCP Server (https://aws-mcp.us-east-1.api.aws/mcp, preview) alongside Amazon EKS/ECS servers that can actually drive an account. The Knowledge server is a safe read-only companion worth enabling; for anything that mutates a client account, codeAmani still goes through the AWS CLI v2 + the v3 SDK + CDK under the [CA] Cloud Architect gate. If you do enable a write-capable AWS-managed server, note it auto-injects the aws:ViaAWSMCPService / aws:CalledViaAWSMCP IAM context keys, so least-privilege policies can distinguish agent-driven calls from human-initiated ones. The [CA] Cloud Architect agent owns IaC and account topology; loop it in for any new account or VPC design.


Accounts & IAM (least-privilege)

Every enterprise client gets an isolated AWS account (ideally under an AWS Organizations management account) so blast radius, billing, and a HIPAA BAA stay scoped per engagement. The non-negotiables:

A minimal scoped policy for a Vercel app that only needs to put/get objects in one bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ScopedBucketAccess",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::acme-health-intake/*"
    }
  ]
}

Secrets Manager

App secrets (DB credentials, third-party API keys) live in AWS Secrets Manager, not in plaintext env files on the instance. This is the AWS-native analogue of the house infisical guide — when a build is on AWS end-to-end, prefer Secrets Manager (it integrates with RDS rotation and IAM); when the app is Vercel-hosted, Infisical/Vercel env vars remain the default and AWS holds only the infrastructure secrets. Encryption is handled by KMS automatically; see the encryption guide for the at-rest/in-transit baseline.

// lib/aws/secrets.ts
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

const client = new SecretsManagerClient({ region: process.env.AWS_REGION });

export async function getSecret<T = Record<string, string>>(secretId: string): Promise<T> {
  const res = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
  if (!res.SecretString) throw new Error(`Secret ${secretId} has no string value`);
  return JSON.parse(res.SecretString) as T;
}

S3 & Presigned URLs

S3 is the AWS object store. Cloudflare R2 is the house default (zero egress fees, S3-compatible API), and most builds never leave R2 — but an enterprise/HIPAA client may require S3 specifically (BAA coverage, s3:ObjectLockConfiguration for WORM retention, SSE-KMS with a customer-managed key, or VPC-gated access). R2 speaks the S3 API, so the v3 @aws-sdk/client-s3 code below works against both; only the endpoint and credentials change.

The pattern for client uploads is the presigned URL: the browser uploads directly to S3, the server never proxies the bytes, and the URL expires. For PHI, the bucket is private with SSE-KMS and the presigned PUT carries the encryption header.

// lib/aws/s3-presign.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.S3_BUCKET!;

/** Presigned PUT for a direct browser upload (PHI: SSE-KMS enforced). */
export async function presignUpload(key: string, contentType: string): Promise<string> {
  const cmd = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
    ServerSideEncryption: "aws:kms",
    SSEKMSKeyId: process.env.S3_KMS_KEY_ID, // customer-managed key for healthcare
  });
  return getSignedUrl(s3, cmd, { expiresIn: 300 }); // 5 minutes
}

/** Presigned GET for a time-boxed download (e.g. an intake PDF). */
export async function presignDownload(key: string): Promise<string> {
  const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key });
  return getSignedUrl(s3, cmd, { expiresIn: 60 });
}

Gotcha: A presigned PUT only succeeds if the request matches the signed parameters exactly. If you sign ServerSideEncryption: "aws:kms", the browser's PUT must send the x-amz-server-side-encryption: aws:kms header — otherwise S3 returns 403 SignatureDoesNotMatch. Sign exactly the headers the client will send, no more.


Databases — RDS & Aurora Serverless v2

Neon is the house default Postgres — serverless, branch-per-PR, generous free tier, and it's already wired into the neon guide and the app's Drizzle setup. Stay on Neon unless an enterprise client needs something Neon can't offer: a signed BAA, a private VPC with no public endpoint, VPC peering to other AWS resources, or a specific data-residency region.

When AWS is required, the recommendation is Aurora Serverless v2 (PostgreSQL-compatible) over plain RDS for most workloads — it autoscales ACUs with load (including scale-to-near-zero on idle), which mirrors Neon's serverless economics while living inside the client's VPC. Reach for provisioned RDS only when a steady, predictable instance class is cheaper or a feature requires it.

Concern House default (Neon) AWS (Aurora Serverless v2 / RDS)
Provisioning Instant, in-console/MCP CDK/Terraform into a VPC
Scaling Serverless autoscale Aurora SLv2 ACUs / RDS instance class
Branching Per-PR DB branches Snapshots/clones (heavier)
Connection Pooled HTTP/WebSocket driver Standard pg over VPC; use RDS Proxy for Lambda
HIPAA BAA Via Neon's terms Covered under AWS BAA (Aurora/RDS are HIPAA-eligible)
Encryption TLS + at-rest TLS + KMS at-rest (required for PHI)

Drizzle still drives it — point the connection string at the Aurora writer endpoint (sourced from Secrets Manager). For Lambda, put RDS Proxy in front to avoid exhausting connections on cold-start storms.


Compute — Lambda + API Gateway & ECS Fargate

Two compute shapes cover the enterprise cases:

Runtimes: target a current managed runtime. Lambda's Node.js line on Amazon Linux 2023 is nodejs22.x, nodejs24.x (the console default), and nodejs26.x; the AL2-based nodejs18.x and earlier are deprecated. In the CDK, prefer Runtime.NODEJS_LATEST (or pin NODEJS_24_X) so functions stay on a supported line. On Fargate, pin the base image to a supported Node.js LTS (22 or 24).

Both run with a scoped task/execution role (no embedded keys) and read secrets from Secrets Manager at start. A typical healthcare platform is: CloudFront → ALB → Fargate service (in private subnets) → Aurora Serverless v2 (in isolated subnets) → S3 for documents.


CloudFront & SES


Infrastructure as Code (AWS CDK in TypeScript)

All AWS infrastructure is defined as code and reviewed by the [CA] Cloud Architect agent before apply — no click-ops in the console for anything that touches client data. The house preference is the AWS CDK v2 in TypeScript (same language as the app, type-safe constructs); Terraform is the alternative when a client standardizes on it or needs multi-cloud.

npm install -g aws-cdk        # CDK v2 CLI (the `cdk` command)
npm install aws-cdk-lib constructs   # in-project library
cdk bootstrap aws://ACCOUNT_ID/us-east-1

A minimal, HIPAA-leaning stack — a private, encrypted, versioned bucket plus a scoped secret:

// lib/intake-stack.ts
import { Stack, StackProps, Duration, RemovalPolicy } from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  Bucket,
  BucketEncryption,
  BlockPublicAccess,
} from "aws-cdk-lib/aws-s3";
import { Key } from "aws-cdk-lib/aws-kms";
import { Secret } from "aws-cdk-lib/aws-secretsmanager";

export class IntakeStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Customer-managed KMS key with rotation (required posture for PHI).
    const key = new Key(this, "IntakeKey", {
      enableKeyRotation: true,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    // Private, encrypted, versioned bucket — no public access.
    new Bucket(this, "IntakeBucket", {
      bucketName: "acme-health-intake",
      encryption: BucketEncryption.KMS,
      encryptionKey: key,
      enforceSSL: true,
      versioned: true,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      lifecycleRules: [{ expiration: Duration.days(2555) }], // ~7yr retention
    });

    // DB credentials in Secrets Manager (consumed by Aurora + the app).
    new Secret(this, "DbCredentials", {
      secretName: "acme/aurora/credentials",
      generateSecretString: {
        secretStringTemplate: JSON.stringify({ username: "app" }),
        generateStringKey: "password",
        excludePunctuation: true,
      },
    });
  }
}
cdk synth      # emit the CloudFormation template (review this in the [CA] gate)
cdk diff       # show the delta against the deployed stack
cdk deploy     # apply (only after Cloud Architect review)

HIPAA on AWS (Florida healthcare niche)

This is the reason AWS exists in the stack. For any build handling PHI for an APD/AHCA/DCF provider, the baseline is non-negotiable:

  1. Sign the BAA. Accept the AWS Business Associate Addendum via AWS Artifact (Console → Artifact → Agreements) before any PHI lands in the account. No BAA, no PHI — full stop.
  2. HIPAA-eligible services only. PHI may only flow through services on the HIPAA-eligible services reference. S3, RDS/Aurora, Lambda, ECS/Fargate, API Gateway, CloudFront, SES, Secrets Manager, KMS are all eligible — but verify each one before use; not every AWS service is covered.
  3. Encryption at rest — KMS on S3 (SSE-KMS), Aurora/RDS storage encryption, EBS volumes. Use customer-managed keys with rotation for the strongest posture.
  4. Encryption in transit — TLS everywhere; enforceSSL on buckets, rds.force_ssl, HTTPS-only CloudFront. See the encryption guide.
  5. Audit + access — CloudTrail on (immutable log bucket), least-privilege IAM, no public endpoints for PHI stores, VPC isolation for databases.
  6. Retention — lifecycle rules / Object Lock to meet record-retention rules (Florida healthcare retention can run ~7 years).
flowchart LR
    A["Provider uploads PHI"] --> B["CloudFront + OAC<br/>HTTPS only"]
    B --> C["Private S3 bucket<br/>SSE-KMS · versioned"]
    A --> D["Fargate service<br/>(private subnet)"]
    D --> E[("Aurora Serverless v2<br/>KMS at-rest · TLS")]
    D --> F["Secrets Manager<br/>DB creds via IAM role"]
    G["AWS Artifact BAA"] -.signed before any PHI.-> C
    H["CloudTrail"] -.audit log.-> C

Compliance gate: PHI work intersects the [COMPLIANCE] and [PRIVACY] agents. The BAA, encryption posture, and the HIPAA-eligible-services check are human approval gates — surface them, don't assume.


Environment Variables

# Region (required by every v3 SDK client)
AWS_REGION=us-east-1

# Static credentials — ONLY where a role can't be attached (e.g. a Vercel app
# calling S3). Scope them to one bucket/action. Prefer roles everywhere else.
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...           # server-side only — NEVER ship to the client bundle

# Local dev / CLI: assume a role from a named profile instead of static keys
AWS_PROFILE=acme-enterprise

# App-level config (sourced from Secrets Manager in production)
S3_BUCKET=acme-health-intake
S3_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/...

Add these to ENV_MASTER.md and each project's .env.example. In production prefer IAM roles (Lambda/ECS/EC2) and Secrets Manager over static keys; static keys are a last resort and must be rotated. Never expose AWS_SECRET_ACCESS_KEY to the browser — presign on the server.


CLI Integration (AWS CLI v2)

Install the AWS CLI v2 (the v1 line is end-of-life-track; always v2). Authenticate with a named profile or SSO — never paste root keys.

# Configure a named profile (or `aws configure sso` for IAM Identity Center)
aws configure --profile acme-enterprise

# Verify which identity you're operating as before anything destructive
aws sts get-caller-identity --profile acme-enterprise

# Create a private, versioned bucket with default SSE-KMS
aws s3api create-bucket --bucket acme-health-intake --region us-east-1 \
  --profile acme-enterprise
aws s3api put-bucket-versioning --bucket acme-health-intake \
  --versioning-configuration Status=Enabled --profile acme-enterprise

# Read a secret (JSON) from Secrets Manager
aws secretsmanager get-secret-value --secret-id acme/aurora/credentials \
  --query SecretString --output text --profile acme-enterprise

# Tail a Lambda's logs live
aws logs tail /aws/lambda/intake-handler --follow --profile acme-enterprise

Automation Workflows

Claude Code slash command: AWS resource audit

.claude/commands/aws-audit.md:

Audit the AWS account for the profile: $ARGUMENTS

1. Run `aws sts get-caller-identity --profile $ARGUMENTS` and confirm the account.
2. List S3 buckets and check each for: public-access block ON, default encryption,
   and versioning (`aws s3api get-bucket-encryption|get-public-access-block|get-bucket-versioning`).
3. List IAM users and flag any with active access keys older than 90 days.
4. Confirm CloudTrail is enabled in all regions.
5. Cross-check every service touching PHI against the HIPAA-eligible services list.
6. Output a findings table: resource, issue, severity, remediation. Flag anything
   that breaks the HIPAA baseline for the [COMPLIANCE] / [PRIVACY] gate.

GitHub Actions: CDK deploy via OIDC (no static keys)

# .github/workflows/cdk-deploy.yml
name: CDK Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write   # required for OIDC role assumption
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: '24' }
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-cdk-deploy
          aws-region: us-east-1
      - run: npm ci
      - run: npx cdk diff
      - run: npx cdk deploy --require-approval never

Use GitHub OIDC to assume a deploy role — no long-lived AWS keys in CI secrets. The role is scoped to the CDK CloudFormation stack only.


Common Use Cases

Use Case Approach
Default object storage Cloudflare R2 (house default) — use S3 only if a client requires it
Default Postgres Neon (house default) — use Aurora Serverless v2 only for VPC/BAA/residency needs
Default transactional email Resend (house default) — use SES only for in-VPC/high-volume/BAA email
Default app hosting Vercel (house default) — use ECS Fargate/Lambda for VPC-resident services
Direct browser upload S3 presigned PUT (@aws-sdk/s3-request-presigner), 5-min expiry
Time-boxed file download S3 presigned GET, short expiry
Infrastructure provisioning AWS CDK v2 (TypeScript), reviewed by [CA]
App/infra secrets AWS Secrets Manager + IAM role (ties to infisical/encryption)
HIPAA PHI store Private S3 + SSE-KMS + Aurora-in-VPC, BAA via AWS Artifact
Bursty / event-driven compute Lambda + API Gateway (+ RDS Proxy for DB)
Long-running / containerized ECS Fargate (ties to docker guide)

Troubleshooting

Issue Fix
SignatureDoesNotMatch on presigned PUT Client must send exactly the signed headers (e.g. x-amz-server-side-encryption); sign only what the client will send
403 AccessDenied on S3 GET Check the IAM policy ARN matches the bucket, and that bucket policy / OAC isn't blocking; confirm AWS_REGION is correct
ExpiredToken from the SDK SSO/role session expired — re-run aws sso login --profile ... or refresh the assumed role
CLI uses the wrong account Pass --profile (or set AWS_PROFILE); verify with aws sts get-caller-identity
Lambda DB connection exhaustion Front the database with RDS Proxy; don't open a raw pool per invocation
cdk deploy fails on bootstrap Run cdk bootstrap aws://ACCOUNT/REGION once per account+region
SES emails go to spam / sandbox Verify domain DKIM/SPF and request production access to leave the SES sandbox
PHI on a non-eligible service Stop — move PHI only onto services on the HIPAA-eligible list, and confirm the BAA is signed in AWS Artifact
KMS AccessDeniedException The IAM role needs kms:GenerateDataKey/kms:Decrypt on the key, and the key policy must allow the role

Official docs:

Better Auth Integration Guide

Technology: better-auth · Category: auth · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/better-auth

Insight:

Better Auth is the self-hosted counterweight to Clerk — the user, session, account and verification tables live in your Postgres, so auth data joins directly against app data and there is no per-MAU bill. You trade a managed dashboard for full ownership: you run the migrations, you secure the tables, you send the emails and the SMS.

██████╗ ███████╗████████╗████████╗███████╗██████╗      █████╗ ██╗   ██╗████████╗██╗  ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗    ██╔══██╗██║   ██║╚══██╔══╝██║  ██║
██████╔╝█████╗     ██║      ██║   █████╗  ██████╔╝    ███████║██║   ██║   ██║   ███████║
██╔══██╗██╔══╝     ██║      ██║   ██╔══╝  ██╔══██╗    ██╔══██║██║   ██║   ██║   ██╔══██║
██████╔╝███████╗   ██║      ██║   ███████╗██║  ██║    ██║  ██║╚██████╔╝   ██║   ██║  ██║
╚═════╝ ╚══════╝   ╚═╝      ╚═╝   ╚══════╝╚═╝  ╚═╝    ╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚═╝  ╚═╝

Better Auth Integration Guide

Focus: framework-agnostic, self-hosted TypeScript auth — email/password, social OAuth, sessions, and a first-party plugin ecosystem (organizations, 2FA, passkeys, phone OTP, Stripe billing) running entirely inside your own Next.js app and your own Postgres.

Overview

Better Auth is a library, not a service. You call betterAuth({...}) in your own code, point it at your own database, and mount a single catch-all route. No external identity provider sits in the request path — signing in writes a row to your session table and sets an HttpOnly cookie signed with your BETTER_AUTH_SECRET.

That inversion is the whole trade-off against Clerk and Auth0:

Clerk / Auth0 Better Auth
User data lives in the vendor's database your Postgres (user, session, account, verification)
Joining users to app data webhook-synced mirror table a plain SQL JOIN
Pricing per monthly-active-user your database bill
Pre-built UI hosted components you build the forms
Email / SMS delivery included you wire it (Resend, Africa's Talking)
Ops burden none migrations, secret rotation, table security
flowchart LR
  A["Browser<br/>authClient"] -->|"fetch /api/auth/*"| B["Catch-all route<br/>toNextJsHandler(auth)"]
  B --> C["betterAuth() instance<br/>lib/auth.ts"]
  C --> D["Adapter<br/>drizzle · prisma · pg"]
  D --> E[("Your Postgres<br/>user · session<br/>account · verification")]
  C --> F["Plugins<br/>organization · 2FA<br/>phoneNumber · stripe"]
  B -.->|"Set-Cookie<br/>HttpOnly · Secure · SameSite=Lax"| A

Official Documentation

Resource URL
Docs home https://www.better-auth.com/docs
Installation https://www.better-auth.com/docs/installation
Next.js integration https://www.better-auth.com/docs/integrations/next
Database & adapters https://www.better-auth.com/docs/concepts/database
Session management https://www.better-auth.com/docs/concepts/session-management
CLI https://www.better-auth.com/docs/concepts/cli
Source https://github.com/better-auth/better-auth

Setup

1. Install

npm install better-auth

2. Generate a secret

BETTER_AUTH_SECRET signs session cookies and encrypts stored OAuth tokens. It must be high-entropy and at least 32 characters. The CLI generates one:

npx auth@latest secret

CLI package name: the current CLI ships as the bare npm package auth — so it is npx auth .... The older @better-auth/cli name is frozen at 1.4.x; do not use it against a current install.

3. Environment variables

# .env.local — server-side only, never NEXT_PUBLIC_*
BETTER_AUTH_SECRET=<32+ char secret from the CLI>
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=<postgres connection string>

# Only for the social providers you actually enable
GOOGLE_CLIENT_ID=<from Google Cloud console>
GOOGLE_CLIENT_SECRET=<from Google Cloud console>

Better Auth resolves the secret as options.secret → BETTER_AUTH_SECRET → AUTH_SECRET, and throws in production when none is set.

4. Create the auth instance

lib/auth.ts — this module imports your database driver and reads the secret, so it is server-only. Never import it from a client component.

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/db";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  emailAndPassword: { enabled: true },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
  },
  plugins: [nextCookies()], // MUST be the last entry in this array
});

Other adapters take the same shape:

import { prismaAdapter } from "better-auth/adapters/prisma";
// database: prismaAdapter(prisma, { provider: "postgresql" })

import { Pool } from "pg";
// database: new Pool({ connectionString: process.env.DATABASE_URL })

nextCookies() works by post-processing the response to write cookies set during server actions. Any plugin listed after it never gets its cookies written — this is the single most common misconfiguration.

5. Create the database tables

With a direct driver (pg, better-sqlite3, mysql2) Better Auth applies migrations itself:

npx auth migrate

With an ORM adapter (Drizzle, Prisma) it emits schema instead, and you run your ORM's own migration tool afterwards:

npx auth generate --adapter drizzle

That produces the four core tables — user, session, account, verification — plus one table per schema-carrying plugin. Field facts worth knowing:

6. Mount the route handler

app/api/auth/[...all]/route.ts:

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth);

7. Create the client

lib/auth-client.ts:

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

export const { signIn, signUp, signOut, useSession } = authClient;

The import path is per framework — better-auth/react, /vue, /svelte, /solid, or the vanilla better-auth/client.


Key patterns

Sign in and sign up

Every client method returns { data, error } — it does not throw.

"use client";
import { authClient } from "@/lib/auth-client";

async function onSubmit(email: string, password: string) {
  const { data, error } = await authClient.signIn.email({ email, password });
  if (error) return showError(error.message);
  router.push("/dashboard");
}

Social sign-in redirects the browser:

await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" });

Reading the session

Server (server component, route handler, server action) — authoritative, hits the database:

import { headers } from "next/headers";
import { auth } from "@/lib/auth";

const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");

Client — reactive hook:

const { data: session, isPending } = authClient.useSession();

In Next.js middleware, check only that the session cookie exists. Do not call the database or the API there; middleware runs on every matched request and will block them.

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  const { pathname } = request.nextUrl;

  if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
  if (!sessionCookie && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/login", "/signup"] };

This is a redirect optimisation, not an authorization boundary. A cookie can be present and invalid. Re-check with auth.api.getSession() inside every protected route, action, and handler.

session: {
  expiresIn: 60 * 60 * 24 * 7, // 7 days total lifetime
  updateAge: 60 * 60 * 24,     // slide the expiry at most once a day
  freshAge: 60 * 5,            // "recently authenticated" window for sensitive ops
  cookieCache: { enabled: true, maxAge: 5 * 60 },
}

cookieCache trades correctness for latency: session reads come from a short-lived signed cookie instead of the database. A revoked session stays live on other devices until maxAge expires. Keep it short, and leave it off wherever immediate revocation is a requirement.

Cookie defaults are already production-shaped: HttpOnly, SameSite=Lax, and Secure auto-enabled when the resolved base URL is HTTPS or NODE_ENV is production.

Plugins

Plugins come in matched server + client pairs, and any that carry schema require a re-run of npx auth generate.

// lib/auth.ts (server)
import { organization, twoFactor, magicLink, admin } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";

plugins: [
  organization(),
  twoFactor(),
  magicLink({ sendMagicLink: async ({ email, url }) => sendEmail(email, url) }),
  admin(),
  passkey(),
  nextCookies(), // always last
]
// lib/auth-client.ts
import {
  organizationClient, twoFactorClient, magicLinkClient, adminClient,
} from "better-auth/client/plugins";
import { passkeyClient } from "@better-auth/passkey/client";

plugins: [organizationClient(), twoFactorClient(), magicLinkClient(), adminClient(), passkeyClient()]

Import-path trap: passkey lives in its own package (@better-auth/passkey and @better-auth/passkey/client). The others are in core (better-auth/plugins and better-auth/client/plugins). Mixing these up is the most common copy-paste failure.

twoFactorClient() takes an onTwoFactorRedirect callback that fires when a sign-in needs a second factor:

twoFactorClient({
  onTwoFactorRedirect({ twoFactorMethods }) {
    window.location.href = "/2fa";
  },
})

Phone-number OTP

Phone auth is a first-class plugin — you supply the delivery function:

import { phoneNumber } from "better-auth/plugins";

phoneNumber({
  otpLength: 6,
  expiresIn: 300,
  requireVerification: true,
  phoneNumberValidator: (n) => /^254\d{9}$/.test(n),
  sendOTP: async ({ phoneNumber, code }) => {
    await sendSms(phoneNumber, `Your code is ${code}`);
  },
})

Client side: authClient.phoneNumber.sendOtp({ phoneNumber }), then authClient.phoneNumber.verify({ phoneNumber, code }).

Rate limiting

Rate limiting runs before any plugin hook or route handler and short-circuits with a 429.

rateLimit: {
  enabled: true,
  window: 10,
  max: 100,
  customRules: { "/sign-in/email": { window: 60, max: 5 } },
  storage: "database",
}

On Vercel, "memory" gives effectively no protection — each function instance keeps its own counter. Use "database", or a secondaryStorage backed by Redis/Upstash.

Database hooks

Lifecycle side-effects, with the ability to abort:

databaseHooks: {
  user: {
    create: {
      after: async (user) => { await provisionWorkspace(user.id); },
    },
    delete: {
      before: async (user) => !user.email.endsWith("@codeamani.com"),
    },
  },
}

Returning false from a before hook aborts the operation.

Stripe billing

@better-auth/stripe binds Stripe customers to Better Auth users, and optionally to organizations:

npm install @better-auth/stripe
import { stripe } from "@better-auth/stripe";

plugins: [
  organization(),
  stripe({
    createCustomerOnSignUp: true,
    subscription: {
      enabled: true,
      plans: [{ name: "pro", priceId: process.env.STRIPE_PRO_PRICE_ID as string }],
    },
    organization: { enabled: true }, // bill the org, not the individual
    onEvent: async (event) => {
      switch (event.type) {
        case "invoice.paid":
          break;
      }
    },
  }),
]

codeAmani notes

When to reach for Better Auth over Clerk

Clerk stays the default for codeAmani projects — hosted UI and zero ops win for most US-first SaaS. Reach for Better Auth when one of these holds:

  1. Auth data must join app data. Multi-tenant reporting, per-user analytics, and admin tooling get dramatically simpler when user is a real table sitting next to your domain tables instead of a webhook-synced mirror.
  2. Per-MAU pricing breaks the model. The Kenya-targeted builds (duka-order-bot, boda-dispatch, sacco-chama-assistant) expect large low-ARPU user counts. A per-MAU bill in USD against KES revenue does not survive contact with the spreadsheet; a Neon or Supabase row does.
  3. Phone-first identity. Many East African users have a reliable phone number and no reliable email. The phoneNumber plugin makes SMS OTP a primary credential rather than a bolt-on — pair sendOTP with Africa's Talking and normalise to 254XXXXXXXXX inside phoneNumberValidator, matching the M-Pesa phone-format rule in MPESA_PATTERNS.md.

Security

Ops

Official docs:

Blockchain Integration Guide

Technology: blockchain · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/blockchain

Insight:

A blockchain is a replicated append-only ledger no single party owns — its one real superpower is verifiable state without a trusted operator, and everything else (the trilemma, gas, finality) is a trade-off around that. Reads are cheap RPC calls; writes are key-signed, gas-costed, and irreversible, so the signing key never touches your server — it lives in the user's wallet or a KMS/HSM. For codeAmani the honest use is USDC on a cheap L2 (Base) as a borderless settlement rail behind an M-Pesa cash-out — stablecoins are the killer app, speculation is not.

██████╗ ██╗      ██████╗  ██████╗██╗  ██╗ ██████╗██╗  ██╗ █████╗ ██╗███╗   ██╗
██╔══██╗██║     ██╔═══██╗██╔════╝██║ ██╔╝██╔════╝██║  ██║██╔══██╗██║████╗  ██║
██████╔╝██║     ██║   ██║██║     █████╔╝ ██║     ███████║███████║██║██╔██╗ ██║
██╔══██╗██║     ██║   ██║██║     ██╔═██╗ ██║     ██╔══██║██╔══██║██║██║╚██╗██║
██████╔╝███████╗╚██████╔╝╚██████╗██║  ██╗╚██████╗██║  ██║██║  ██║██║██║ ╚████║
╚═════╝ ╚══════╝ ╚═════╝  ╚═════╝╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝

Blockchain Integration Guide

Focus: what a blockchain actually is (a replicated append-only ledger nobody owns), the trade-off that governs every design choice (the trilemma), and where it earns its keep for codeAmani — stablecoin settlement and remittance rails that complement M-Pesa, not replace it. EVM-anchored, read-mostly, secrets server-side.

Overview

A blockchain is a shared, append-only ledger replicated across thousands of independent computers, where new entries ("blocks") are only accepted if a majority agree they follow the rules. Each block carries the cryptographic hash of the one before it, so the chain is tamper-evident: change one historical transaction and every later block's hash breaks, and the network rejects your fork. No single party — no bank, no company, no government — can silently edit it. That single property (verifiable state without a trusted operator) is the whole point; everything else is engineering trade-offs around it.

Three honest truths frame every blockchain decision:

  1. You can't have it all — the trilemma. A chain optimises at most two of decentralisation, security, and scalability. Bitcoin and Ethereum L1 pick decentralisation + security and pay with low throughput and higher fees. High-TPS L1s buy scalability by reducing the number of validators (less decentralised). L2 rollups (Base, Arbitrum, Optimism) are today's pragmatic answer: they execute cheaply off-chain and inherit Ethereum's security by posting proofs back to L1.
  2. Reads are free and easy; writes are slow, public, and cost gas. Querying chain state (a balance, a token's supply) is a cheap RPC call. Writing (a transfer, a contract call) is signed by a private key, broadcast to the mempool, included in a block by a validator, and costs a gas fee. Until it has enough confirmations it can still be reorged — writes are eventually-final, not instantly-final.
  3. Code is law, and bugs are permanent. A deployed smart contract is immutable and usually controls real money. There is no "undo," no support line. This is why audits, well-trodden token standards (ERC-20, ERC-721), and "don't roll your own crypto" matter more here than almost anywhere else.

For codeAmani, the realistic, non-speculative use is digital dollars on a cheap fast chain: USDC/USDT (stablecoins) on an L2 settle cross-border value in seconds for cents, which is a genuine complement to M-Pesa's domestic strength. The rest of this guide is EVM-centric (Ethereum + its L2s) because that ecosystem has the deepest tooling — viem and ethers — and the widest stablecoin liquidity.

Official Documentation

Source URL What it covers
Ethereum dev docs https://ethereum.org/en/developers/docs/ Accounts, transactions, gas, EVM, consensus — the canonical mental model
viem https://viem.sh/docs/getting-started Modern TypeScript Ethereum interface (clients, reads, writes, ABIs)
ethers v6 https://docs.ethers.org/v6/ Mature JS library — providers, contracts, wallets, formatting
Solidity https://docs.soliditylang.org/ The dominant smart-contract language
Circle (USDC) https://developers.circle.com/stablecoins Stablecoin standards, supported chains, on/off-ramp APIs

How a block chain holds together

Each block bundles a batch of transactions plus the hash of the previous block. Because the hash is derived from the contents, any change anywhere ripples forward and invalidates every subsequent block — that's what makes the ledger tamper-evident rather than merely "a database with backups."

flowchart LR
    G["Block 0 · Genesis<br/>hash: 0x9f…"] --> B1["Block 1<br/>prev: 0x9f…<br/>txs · hash: 0x3a…"]
    B1 --> B2["Block 2<br/>prev: 0x3a…<br/>txs · hash: 0x7c…"]
    B2 --> B3["Block 3<br/>prev: 0x7c…<br/>txs · hash: 0x11…"]
    B3 --> B4["Block N · pending<br/>prev: 0x11…<br/>mempool txs"]

Consensus is how the network agrees on which block is next. Ethereum uses Proof of Stake: validators lock up (stake) ETH and are chosen to propose/attest blocks; misbehaviour gets their stake slashed. This replaced energy-hungry Proof of Work and is why "Ethereum is bad for the environment" is now outdated.


The transaction lifecycle (a write)

Reading is a free RPC query. Writing money or state is the part with real consequences — sign locally, broadcast, wait for inclusion, then wait for finality.

sequenceDiagram
    participant App as Your app
    participant W as Wallet / signer
    participant M as Mempool
    participant V as Validator
    participant C as Chain
    App->>W: Build tx (to, value, data)
    W->>W: Sign with private key (never leaves the wallet)
    W->>M: Broadcast signed tx
    M->>V: Validator picks txs (often highest gas first)
    V->>C: Include tx in a block
    C-->>App: 1 confirmation (could still reorg)
    C-->>App: N confirmations → final
    Note over App,C: Reads are instant & free · writes cost gas & take time

Key terms you must internalise:

Term Meaning
Account / address 0x… 20-byte identifier. EOA = controlled by a private key; contract account = controlled by code.
Private key / seed phrase The secret that authorises spends. Whoever holds it owns the funds. Never on a server, never in git, never NEXT_PUBLIC_*.
Gas Compute units a tx consumes × gas price (in gwei). The fee. Failed txs still cost gas.
Nonce Per-account counter; orders an account's txs and prevents replays.
Confirmation / finality Blocks built on top of yours. More confirmations = harder to reverse. Treat money as received only after your finality threshold.
Wei / gwei / ether Denominations. 1 ether = 10⁹ gwei = 10¹⁸ wei. Always work in the smallest unit (bigint); format only for display.

Reading the chain (viem)

viem is the modern, type-safe TypeScript interface. A Public Client reads; install and query in three lines. Source: viem getting-started + clients/public docs.

npm i viem
// lib/chain.ts
import { createPublicClient, http } from "viem";
import { base } from "viem/chains"; // Base = cheap Ethereum L2 — good default for payments

// A public client is read-only. The transport is your RPC endpoint.
export const publicClient = createPublicClient({
  chain: base,
  transport: http(process.env.RPC_URL), // server-side RPC; falls back to a public node if omitted
});

// Cheap, free reads:
const block = await publicClient.getBlockNumber();
const wei = await publicClient.getBalance({ address: "0xA0Cf…251e" });

Reading an ERC-20 (e.g. a USDC balance)

Tokens like USDC are smart contracts, not native chain balance — you read them by calling balanceOf on the contract. Pattern straight from viem's reading-contracts example.

import { erc20Abi, formatUnits } from "viem";
import { publicClient } from "./chain";

const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base

const [symbol, decimals, raw] = await Promise.all([
  publicClient.readContract({ address: USDC, abi: erc20Abi, functionName: "symbol" }),
  publicClient.readContract({ address: USDC, abi: erc20Abi, functionName: "decimals" }),
  publicClient.readContract({ address: USDC, abi: erc20Abi, functionName: "balanceOf", args: ["0xA0Cf…251e"] }),
]);

const human = formatUnits(raw, decimals); // e.g. "42.5" → never do float math on raw bigint balances

The same read in ethers v6

ethers is the mature alternative; many existing dapps use it. Source: ethers v6 getting-started.

npm i ethers
import { JsonRpcProvider, Contract, formatUnits } from "ethers";

const provider = new JsonRpcProvider(process.env.RPC_URL);
const abi = [
  "function decimals() view returns (uint8)",
  "function symbol() view returns (string)",
  "function balanceOf(address) view returns (uint)",
];
const usdc = new Contract("0x8335…2913", abi, provider);

const decimals = await usdc.decimals();
const balance = await usdc.balanceOf("0xA0Cf…251e");
const human = formatUnits(balance, decimals);

viem vs ethers, for a new codeAmani project: prefer viem — smaller bundle (matters on 2G/3G), first-class TypeScript inference, and it pairs with wagmi for React wallet hooks. Use ethers when integrating with an existing codebase or tutorial that already assumes it.


Writing the chain (and why the key stays off your server)

A write is signed by a private key. There are two safe places for that key, and your app server is never one of them:

// Frontend write with a browser wallet (viem) — simulate first, then send.
import { createWalletClient, custom, parseUnits, erc20Abi } from "viem";
import { base } from "viem/chains";
import { publicClient } from "./chain";

const walletClient = createWalletClient({ chain: base, transport: custom(window.ethereum) });
const [account] = await walletClient.getAddresses();

// 1. Simulate — catches reverts BEFORE the user pays gas.
const { request } = await publicClient.simulateContract({
  account,
  address: "0x8335…2913", // USDC
  abi: erc20Abi,
  functionName: "transfer",
  args: ["0xRecipient…", parseUnits("10", 6)], // 10 USDC (6 decimals)
});
// 2. The wallet signs & broadcasts; the key never leaves the wallet.
const hash = await walletClient.writeContract(request);
// 3. Wait for finality before treating it as paid.
const receipt = await publicClient.waitForTransactionReceipt({ hash, confirmations: 3 });

The simulate → write → wait sequence is the canonical safe pattern: simulation surfaces a revert before any gas is spent, and waitForTransactionReceipt is where you enforce your finality threshold.


Smart contracts in one breath

A smart contract is code (usually Solidity) deployed to an address that runs on the EVM exactly as written, by every validator, forever. You mostly consume existing contracts via their ABI (the JSON describing their functions) rather than writing your own. When you do write one:


Possible use cases for codeAmani (mapped to the existing stack)

Blockchain is not a replacement for the repo's rails — it's a settlement layer that plugs into them. Each row pairs a real product idea with the tech-stack guide it builds on.

flowchart TD
    subgraph OnChain["On-chain (settlement)"]
      U["USDC on Base L2"]
    end
    subgraph Rails["codeAmani rails (existing guides)"]
      M["M-Pesa / Daraja"]
      S["Stripe"]
      DB["Supabase / Neon ledger"]
      AT["Africa's Talking / WhatsApp"]
    end
    U <-->|on/off-ramp| M
    U <-->|card top-up| S
    U -->|index events| DB
    U -->|status alerts| AT
Use case What blockchain adds Builds on (repo guide)
Cross-border remittance / settlement USDC on an L2 moves value across borders in seconds for cents, then off-ramps to M-Pesa locally. Cheaper and faster than correspondent banking for diaspora → Kenya flows. MPESA_PATTERNS.md, daraja-api, stripe (card on-ramp)
Stablecoin payouts to SMEs/creators Pay suppliers or gig workers in digital dollars; they hold value against KES inflation, cash out to M-Pesa on demand. daraja-api, africas-talking
On-chain proof / provenance Anchor a hash of a document, certificate, or supply-chain event on-chain for tamper-evident verification — cheap, no token speculation. supabase (store the doc, index the tx)
Auditable payment ledger Mirror on-chain transfers into Postgres so your app reads from a fast DB, with the chain as the source of truth. Dedupe on tx hash, exactly like webhook idempotency. supabase/neon, webhooks
Wallet-based identity / access "Sign-In with Ethereum" (a signed message, no gas) as a passwordless login or token-gated access, alongside Clerk for email/social. clerk (hybrid auth)
Transparent disbursements (NGO/treasury) Publicly verifiable fund flows for grants or community payouts — anyone can audit, no trust in a single operator. supabase (off-chain metadata)

The honest framing for the East African market: stablecoins are the killer app, speculation is not. USDC settlement complements M-Pesa's last-mile reach; treat the chain as a fast, borderless clearing layer and let Daraja handle the cash-in/cash-out that users actually touch.


On/off-ramp reality (the hard part)

Moving between fiat (KES) and on-chain dollars is a regulated, partner-dependent step, not an API you self-host:


Security checklist


codeAmani notes

Official docs:

Caching Integration Guide

Technology: caching · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/caching

Insight:

A cache is a faster copy of a slower truth, and the whole discipline reduces to one trade-off: how long the stored copy is allowed to lie versus how precisely you can tell it to stop. Caching is the single biggest perceived-speed lever for a low-bandwidth audience — an East-African edge hit instead of a US-origin round-trip is the difference between 400 ms and several seconds. The codeAmani default is stale-while-revalidate at the edge plus tag-based invalidation on the write path, with Upstash Redis for non-HTTP values (Daraja token reuse, STK-Push idempotency). As of Next.js 16 the framework layer has shifted to the stable use cache directive + Cache Components; the classic fetch/unstable_cache model still works when Cache Components is off.

 ██████╗ █████╗  ██████╗██╗  ██╗██╗███╗   ██╗ ██████╗
██╔════╝██╔══██╗██╔════╝██║  ██║██║████╗  ██║██╔════╝
██║     ███████║██║     ███████║██║██╔██╗ ██║██║  ███╗
██║     ██╔══██║██║     ██╔══██║██║██║╚██╗██║██║   ██║
╚██████╗██║  ██║╚██████╗██║  ██║██║██║ ╚████║╚██████╔╝
 ╚═════╝╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝

Caching Integration Guide

Focus: a cache is a faster copy of a slower truth. The whole game is deciding how long the copy is allowed to lie and how you tell it to stop lying. This guide walks the five layers a codeAmani request passes through — browser, CDN/edge, Next.js, application (Redis), origin — and treats invalidation as the part that's actually hard.

Overview

Every cache is the same trade: serve a stored copy instead of recomputing, accepting that the copy may be stale. On a 2G/3G connection in Nairobi, that trade is not a micro-optimisation — it's the difference between a page that paints in 400 ms from a Mombasa edge node and one that round-trips 180 ms each way to a US origin for every byte. Caching is how an African-market product feels fast on a slow network.

There is no single cache. A request flows through a stack of them, each with its own TTL, its own key, and its own invalidation story:

flowchart LR
    A["Browser<br/>Cache-Control · Cache API"] --> B["CDN / Edge<br/>Vercel · Cloudflare<br/>s-maxage · SWR"]
    B --> C["Next.js<br/>Full Route + Data Cache<br/>revalidateTag"]
    C --> D["App cache<br/>Upstash Redis<br/>SET ... EX"]
    D --> E["Origin<br/>Postgres · Daraja · LLM"]
    style A fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style B fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style C fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style D fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style E fill:#2a2a2a,stroke:#888,color:#fff

The closer to the user a layer sits, the cheaper and faster the hit — but the harder it is to reach in and invalidate. A browser cache you cannot purge at all (only expire); a Redis key you can DEL in a millisecond. Design accordingly: put volatile data in layers you control, stable data in layers near the user.

Two famous truths frame the rest of this guide. Phil Karlton: "There are only two hard things in computer science: cache invalidation and naming things." And the operational corollary — a cache hit is a guess that the world hasn't changed. Every section below is about making that guess safely.

Official Documentation

Source URL What it covers
MDN HTTP Caching https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching Cache-Control directives, ETag/If-None-Match, 304 flow
Next.js Cache Components (16) https://nextjs.org/docs/app/getting-started/cache-components cacheComponents, 'use cache', cacheLife/cacheTag, updateTag, PPR — the current model
Next.js classic caching https://nextjs.org/docs/app/guides/caching-without-cache-components Data Cache, Full Route Cache, fetch opt-in, revalidateTag/revalidatePath, legacy unstable_cache
Vercel CDN cache https://vercel.com/docs/caching/cdn-cache s-maxage, CDN-Cache-Control, Vercel-CDN-Cache-Control, x-vercel-cache
Cloudflare cache control https://developers.cloudflare.com/cache/concepts/cache-control/ Origin cache control, s-maxage, SWR, CF-Cache-Status, Cache-Tag
Upstash Redis (TS) https://upstash.com/docs/redis/sdks/ts/getstarted REST client (@upstash/redis 1.38.2), set with { ex } TTL, env config

Layer 1 — HTTP caching (Cache-Control, ETag, SWR)

The HTTP cache is the foundation every other layer builds on. It is driven entirely by response headers — no library, no SDK. Get these right and the browser, the CDN, and any intermediary proxy all cooperate for free.

The directives that matter

Directive Meaning Use for
max-age=N Fresh for N seconds in any cache (incl. browser) Per-user data the browser may keep
s-maxage=N Fresh for N seconds in shared caches (CDN); overrides max-age there CDN/edge TTL distinct from browser
stale-while-revalidate=N Serve stale up to N s while refreshing in background Anything where instant > perfectly fresh
no-cache Store, but revalidate every time before reuse HTML that changes but supports ETag
no-store Never store anywhere Auth tokens, M-Pesa callbacks, PII
private Browser only, never a shared cache Personalised responses
public Cacheable even with Authorization Shared, non-sensitive assets
immutable Content will never change — skip revalidation entirely Hashed/fingerprinted static assets

The two patterns you'll write most:

# Fingerprinted asset (app-abc123.js) — cache forever, it can never change
Cache-Control: public, max-age=31536000, immutable

# Dynamic JSON — instant from cache, refresh in the background, edge TTL 60s
Cache-Control: public, max-age=10, s-maxage=60, stale-while-revalidate=300

stale-while-revalidate: the African-market default

SWR is the single most valuable directive for a low-bandwidth audience. It decouples latency from freshness: the user always gets an instant response from cache, and the cache refreshes itself out of band. The cost of a slow origin is paid by a background fetch, never by the user staring at a spinner on a 2G connection.

sequenceDiagram
    participant U as User (2G)
    participant C as Cache (edge)
    participant O as Origin
    Note over C: max-age=60, stale-while-revalidate=300
    U->>C: GET /prices  (t=0, fresh)
    C-->>U: 200 cached · instant
    U->>C: GET /prices  (t=90s, STALE but in SWR window)
    C-->>U: 200 STALE · instant (no wait!)
    C->>O: background revalidate
    O-->>C: fresh copy stored
    U->>C: GET /prices  (t=120s)
    C-->>U: 200 fresh · instant

The user at t=90s never waits for the origin even though the data was stale — they get the old copy instantly, and the next visitor gets the refreshed one.

ETag / If-None-Match: cheap revalidation

When content must be revalidated (no-cache, or a stale max-age), an ETag turns a full re-download into a tiny 304 Not Modified. The server hashes the body into an ETag; the browser echoes it back as If-None-Match; if unchanged, the server replies 304 with no body.

# First response
HTTP/1.1 200 OK
ETag: "v2-9f3a1c"
Cache-Control: no-cache

# Browser revalidates
GET /api/profile  →  If-None-Match: "v2-9f3a1c"

# Unchanged — body skipped, bytes saved
HTTP/1.1 304 Not Modified

On a metered Kenyan data plan, a 304 is the difference between paying for 40 KB of JSON and paying for ~200 bytes of headers. In Next.js Route Handlers you can set this directly:

// app/api/profile/route.ts
export async function GET(req: Request): Promise<Response> {
  const profile = await getProfile();
  const etag = `"v2-${hash(profile)}"`;
  if (req.headers.get("if-none-match") === etag) {
    return new Response(null, { status: 304, headers: { ETag: etag } });
  }
  return Response.json(profile, {
    headers: { ETag: etag, "Cache-Control": "private, no-cache" },
  });
}

Never cache secrets. Auth tokens, M-Pesa credentials, and PII responses get Cache-Control: no-store. Vercel's CDN already refuses to cache any response carrying Set-Cookie or Authorization, but be explicit — don't rely on the platform to save you.


Layer 2 — CDN / edge caching (Vercel + Cloudflare)

The CDN is a shared cache sitting in dozens of cities, including ones close to East African users. It keys on the URL (plus any Vary headers) and obeys s-maxage. This is where a single origin render gets amortised across thousands of visitors.

Vercel

Vercel's CDN caches a function/SSR response when the Cache-Control header contains s-maxage (with optional stale-while-revalidate). It also honours targeted headers so you can give the edge, downstream CDNs, and the browser different TTLs in one response:

// app/api/catalog/route.ts — browser 10s, downstream CDN 60s, Vercel edge 1h
export async function GET() {
  return Response.json(await getCatalog(), {
    headers: {
      "Cache-Control": "public, max-age=10",
      "CDN-Cache-Control": "public, s-maxage=60",
      "Vercel-CDN-Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
    },
  });
}

Inspect the x-vercel-cache response header to see what happened: HIT, MISS, STALE (served stale, revalidating), or PRERENDER. That header is your first debugging stop when a page "won't update" — a HIT means you're looking at the cache, not the origin.

Vercel strips s-maxage and stale-while-revalidate from the header sent to the browser if you don't also set CDN-Cache-Control, so the browser only sees max-age. It also does not currently support stale-if-error or proxy-revalidate for server-side caching.

Cloudflare

Cloudflare honours origin Cache-Control/s-maxage (Origin Cache Control is on by default) and supports stale-while-revalidate fully asynchronously — expired requests return stale content immediately with a background refresh. Read the CF-Cache-Status header (HIT, MISS, EXPIRED, REVALIDATED, UPDATING, BYPASS) to diagnose behaviour.

Cloudflare's killer feature for invalidation is the Cache-Tag response header: attach tags to a response, then purge every response carrying a tag in one API call — tag-based invalidation at the CDN layer (Enterprise; Cache Reserve / Workers KV give similar control on other plans).

Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Cache-Tag: catalog, prices, vendor-42
# Purge everything tagged "prices" across the whole edge in one shot
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
  -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
  --data '{"tags":["prices"]}'

Layer 3 — Next.js caching (two models)

Next.js layers several caches on top of HTTP. As of Next.js 16 there are two ways to drive them, and which one you're in changes the API you reach for:

Both models share the same underlying caches:

Cache Scope Lives Invalidated by
Request Memoization Single render pass Memory Automatic (per request)
Data Cache / 'use cache' Across requests/users Server revalidate/cacheLife (time), revalidateTag/updateTag/revalidatePath (on-demand)
Full Route Cache A whole rendered route Server Data Cache revalidation, redeploy
Router Cache Client-side nav Browser memory Time, router.refresh(), server action

Request memoization

Within one render, multiple fetch() calls to the same URL hit the network once — React/Next dedupes them. For non-fetch data access (an ORM, the Supabase client), wrap it in React's cache() to get the same dedupe:

import { cache } from "react";
export const getVendor = cache(async (id: string) => db.vendor.findUnique({ where: { id } }));

Classic: Data Cache + time-based revalidation

fetch is not cached by default (unchanged since Next.js 15) — opt in. Time-based revalidation gives you ISR-style behaviour: serve cached, regenerate after N seconds.

// Cached, regenerates at most once per hour (ISR)
const data = await fetch("https://api/...", { next: { revalidate: 3600 } });

// Explicitly cache a one-off fetch
const stable = await fetch("https://api/...", { cache: "force-cache" });

// For non-fetch (DB) calls — legacy in 16, still works when Cache Components is off
import { unstable_cache } from "next/cache";
export const getCachedVendor = unstable_cache(
  async (id: string) => db.vendor.findUnique({ where: { id } }),
  ["vendor"],               // key prefix
  { tags: ["vendor"], revalidate: 3600 },
);

unstable_cache is now legacy. Next.js 16 replaces it with the 'use cache' directive; the API still ships and works (its docs page is titled "unstable_cache (legacy)"), but new code should prefer 'use cache' — especially once Cache Components is enabled.

Next.js 16: the 'use cache' directive

With cacheComponents: true, annotate a file, component, or function with 'use cache' and it is cached automatically — the cache key is derived from the function's arguments and closure, so there is no manual key array. Pair it with cacheLife() (TTL) and cacheTag() (invalidation handle):

// next.config.ts → { cacheComponents: true }
import { cacheLife, cacheTag } from "next/cache";

async function getVendors() {
  "use cache";
  cacheTag("vendors");          // invalidation handle
  cacheLife("hours");           // built-in profile: minutes | hours | days | weeks | max
  return db.select().from(vendors);
}

cacheLife also takes an inline shape — cacheLife({ stale: 3600, revalidate: 7200, expire: 86400 }). You cannot read cookies()/headers()/searchParams inside 'use cache' (pass them as arguments, or use 'use cache: private' for compliance cases where you must).

Tag-based, on-demand invalidation — the good part

Time-based revalidation is a guess at how often data changes. Tag-based invalidation is precise: tag the data when you read it, then blow that tag away when you write. Next.js 16 splits this into two functions:

"use server";
import { revalidateTag, updateTag, revalidatePath } from "next/cache";

// read-your-own-writes: caller sees fresh data THIS request (Server Actions only)
export async function addVendor(form: FormData) {
  await db.vendor.create({ /* ... */ });
  updateTag("vendors");                 // immediate expiry, next request blocks for fresh data
  revalidatePath("/vendors");           // also bust the Full Route Cache for this URL
}

// background SWR: mark stale, refresh on next visit (Server Actions AND Route Handlers)
export async function onWebhook() {
  revalidateTag("vendors", "max");      // NOTE: two-arg in 16 — "max" = stale-while-revalidate
}

Signature change in Next.js 16. revalidateTag now takes a second argument: revalidateTag(tag, profile). The recommended "max" gives stale-while-revalidate (serve stale, refresh in background); revalidateTag(tag, { expire: 0 }) forces immediate expiry for webhooks. The single-argument revalidateTag(tag) is deprecated — it still works if TypeScript errors are suppressed but may be removed. For read-your-own-writes inside a Server Action, prefer the new updateTag(tag) (single-arg, immediate, Server-Action-only). revalidatePath is unchanged (optional 'page' | 'layout' second arg). All of these mark data stale on the server — they do not purge the Vercel/Cloudflare CDN edge, which you invalidate separately (Layer 2).


Layer 4 — Application caching (Upstash Redis)

When the thing you're caching isn't an HTTP response — a computed result, a DB aggregate, a short-lived token — you reach for an application cache. Upstash Redis is the codeAmani default: serverless, REST-based (works from Edge runtime and Vercel functions with no TCP connection), pay-per-request.

The package: @upstash/redis. Env: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN.

// lib/cache.ts
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

/** Cache-aside: try cache, fall back to origin, backfill with a TTL. */
export async function cached<T>(key: string, ttlSec: number, fetcher: () => Promise<T>): Promise<T> {
  const hit = await redis.get<T>(key);
  if (hit !== null && hit !== undefined) return hit; // HIT

  const fresh = await fetcher();                      // MISS → compute
  await redis.set(key, fresh, { ex: ttlSec });        // backfill with TTL
  return fresh;
}

The TTL on set(..., { ex }) is your safety net: even if you forget to invalidate, the key self-destructs. Always set a TTL — an unexpired key is a future stale-data bug.

The two M-Pesa cases this exists for

Daraja OAuth token caching. The Daraja token is valid for ~1 hour. Don't re-OAuth on every STK Push — cache it just under its lifetime so you always refresh before expiry:

async function darajaToken(): Promise<string> {
  return cached("daraja:token", 3000, async () => {  // 50 min < 60 min TTL
    const res = await fetch(`${DARAJA_BASE}/oauth/v1/generate?grant_type=client_credentials`, {
      headers: { Authorization: `Basic ${basicAuth()}` },
    });
    return (await res.json()).access_token as string;
  });
}

STK Push idempotency. Store the CheckoutRequestID the moment the STK Push returns, using set with NX so a duplicate callback is a no-op. This is the dedupe key from the webhooks guide, backed by Redis instead of a unique DB constraint:

// Returns true only the FIRST time we see this CheckoutRequestID
const first = await redis.set(`mpesa:cri:${id}`, "1", { nx: true, ex: 86400 });
if (!first) return; // duplicate callback → already processed, ignore

Cache invalidation — the actual hard problem

Everything above is easy. Invalidation is where systems rot. The core tension: the longer the TTL, the better the hit rate — and the longer wrong data is served. There is no universally correct TTL; there is only a deliberate choice per data type.

TTL vs tag-based: pick by whether you know when data changes

Time-based (TTL) Tag/event-based
Idea Expire after N seconds, hope that's often enough Invalidate the instant the data actually changes
Staleness Up to the full TTL Near-zero
Best for Data that drifts predictably (exchange rates, leaderboards) Data with a clear write/mutation event (a vendor edits a price)
Cost Wasted refreshes / stale windows Must wire every writer to invalidate
codeAmani layer CDN s-maxage, fetch revalidate/cacheLife, Redis ex updateTag/revalidateTag(tag,"max"), Cloudflare Cache-Tag, Redis DEL on write

The mature pattern combines them: a long TTL as a backstop plus tag invalidation for correctness. Tags handle the known changes; the TTL guarantees nothing is stale forever even if an invalidation is missed (and one always eventually is).

Practical rules


Debugging: which layer is lying?

A stale page is almost always one layer holding an old copy. Walk the stack from the user inward:

Symptom Check Header / signal
Browser shows old page DevTools → Network → Disable cache Cache-Control, Age
Edge serving stale Response header x-vercel-cache (HIT/STALE) · CF-Cache-Status
Next.js route won't update Did a mutation call revalidateTag/revalidatePath? —
Redis returns old value redis.ttl(key) — is it expiring? Did the writer DEL? TTL value

A HIT anywhere means you're being served a cached copy — that's the layer to invalidate, not the origin.


codeAmani notes

Official docs:

Canva Integration Guide

Technology: canva · Category: design · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/canva

Insight:

Canva's Connect API mass-produces on-brand graphics — design a Brand Template once, then autofill it from data to generate social/marketing assets at scale. Highest-leverage feature for non-designers; the OAuth client secret and tokens stay server-side.

 ██████╗ █████╗ ███╗   ██╗██╗   ██╗ █████╗
██╔════╝██╔══██╗████╗  ██║██║   ██║██╔══██╗
██║     ███████║██╔██╗ ██║██║   ██║███████║
██║     ██╔══██║██║╚██╗██║╚██╗ ██╔╝██╔══██║
╚██████╗██║  ██║██║ ╚████║ ╚████╔╝ ██║  ██║
 ╚═════╝╚═╝  ╚═╝╚═╝  ╚═══╝  ╚═══╝  ╚═╝  ╚═╝

Canva Integration Guide

Focus: Programmatic design with the Canva Connect API (REST + OAuth) — create designs, autofill brand templates, upload assets, and export to PNG/PDF/JPG. A Canva MCP connector is also available inside Claude Code for design operations.

Overview

Canva exposes two developer surfaces:

No server SDK is required — the Connect API is plain REST/OAuth. Canva does not publish an npm client for the Connect API; instead it ships a public OpenAPI description (https://www.canva.dev/sources/connect/api/latest/api.yml, API version 2024-06-18) you can feed to openapi-generator to generate a typed client in any language, plus a Starter Kit repo (github.com/canva-sdks/canva-connect-api-starter-kit) that bundles a generated TypeScript client and a demo app. In Claude Code, the Canva MCP also offers search-designs, create-design, export-design, upload-asset-from-url, etc.

Here is the big picture — once you see how the pieces connect, the rest is easy:

flowchart LR
  A["Your server<br/>codeAmani"] -->|"OAuth 2.0 Bearer token"| B["Connect API<br/>api.canva.com/rest/v1"]
  B --> C["Create design"]
  B --> D["Upload asset"]
  B --> E["Autofill<br/>Brand Template"]
  B --> F["Export job<br/>PNG/PDF/JPG"]
  F --> G["Download URLs<br/>expire after 24h"]

Official Documentation

The API reference is organized per resource (there is no single /api-reference/ index page).

Resource URL
Connect API docs https://www.canva.dev/docs/connect/
Quickstart + Starter Kit https://www.canva.dev/docs/connect/quickstart/
Authentication (OAuth) https://www.canva.dev/docs/connect/authentication/
Autofill guide https://www.canva.dev/docs/connect/autofill-guide/
Autofills reference https://www.canva.dev/docs/connect/api-reference/autofills/
Exports reference https://www.canva.dev/docs/connect/api-reference/exports/
Assets reference https://www.canva.dev/docs/connect/api-reference/assets/
OpenAPI description https://www.canva.dev/docs/connect/api-reference/openapi-description/
Apps SDK https://www.canva.dev/docs/apps/
Canva MCP server https://www.canva.dev/docs/apps/mcp-server/
Developer portal https://www.canva.dev/

1. Authentication (OAuth 2.0)

  1. Create an integration in the Developer portal and note the Client ID / Client Secret.
  2. Send the user to https://www.canva.com/api/oauth/authorize (Authorization Code + PKCE) requesting the scopes you need (e.g. design:content:write, asset:write, design:content:read), then exchange the returned code for a user access token.
  3. Exchange/refresh at the token endpoint: POST https://api.canva.com/auth/v1/oauth/token. (The older https://api.canva.com/rest/v1/oauth/token host still works but is now deprecated — prefer the /auth host.) Authenticate the request with HTTP Basic auth — Authorization: Basic base64(client_id:client_secret) (recommended) — or with client_id/client_secret body params.
  4. Call the API with Authorization: Bearer <token>. Access tokens now expire after ~4 hours (expires_in is 14400, up from the earlier 1 hour, and is "subject to change"), so read expires_in and refresh proactively.

Scopes are not cumulative — asset:write does not imply asset:read; request each scope you use. Note the exact scope spelling for templates is brandtemplate:meta:read / brandtemplate:content:read (no underscore). Store the client secret + tokens server-side only (codeAmani: .env.local / Vercel env).

2. Create a design

curl -X POST 'https://api.canva.com/rest/v1/designs' \
  -H "Authorization: Bearer $CANVA_TOKEN" -H "Content-Type: application/json" \
  -d '{"type":"type_and_asset","design_type":{"type":"preset","name":"doc"},
       "asset_id":"Msd59349ff","title":"My Holiday Presentation"}'

3. Export a design (async job)

Exports are asynchronous — kick off the job, then poll until it is ready. This little dance is quick to wire up:

sequenceDiagram
  participant S as "Your server"
  participant API as "Connect API"
  S->>API: "POST /exports - design_id + format"
  API-->>S: "job id + status in_progress"
  loop "until status success"
    S->>API: "GET /exports/jobId"
    API-->>S: "status + urls when done"
  end
  S->>S: "Download files before 24h expiry"
# Start the export job
curl -X POST 'https://api.canva.com/rest/v1/exports' \
  -H "Authorization: Bearer $CANVA_TOKEN" -H "Content-Type: application/json" \
  -d '{"design_id":"DAVZr1z5464","format":{"type":"pdf"}}'
# -> { "job": { "id": "...", "status": "in_progress" } }

Poll GET /rest/v1/exports/{jobId} until status is success; the response urls[] are download links that expire after 24h (failures return an error.code such as license_required).

Brand Template autofill

This is the highest-leverage feature: design a Brand Template once in Canva, then POST /v1/autofills with a data object to mass-produce on-brand graphics from your data. The data keys must match the named fields inside the template, and each value declares a type (text with text, image with an asset_id, or chart with chart_data). Like exports, autofill is an async job — kick it off, then poll until status is success and read the produced design from job.result.design.

sequenceDiagram
  participant S as "Your server"
  participant API as "Connect API"
  S->>API: "POST /autofills - brand_template_id + data"
  API-->>S: "job id + status in_progress"
  loop "until status success"
    S->>API: "GET /autofills/jobId"
    API-->>S: "status + result.design when done"
  end
  S->>S: "Use design.id or open edit_url"
# Start the autofill job — keys (price, hero) must match the template's named fields
curl -X POST 'https://api.canva.com/rest/v1/autofills' \
  -H "Authorization: Bearer $CANVA_TOKEN" -H "Content-Type: application/json" \
  -d '{"brand_template_id":"DAFVztcvd9z","title":"M-Pesa promo - June",
       "data":{
         "price":{"type":"text","text":"KES 499"},
         "hero":{"type":"image","asset_id":"Msd59349ff"}
       }}'
# -> { "job": { "id": "...", "status": "in_progress" } }

Poll GET /rest/v1/autofills/{jobId} until status is success; the new design is at job.result.design (id, plus urls.edit_url / urls.view_url) and is saved to the user's Canva account. Requires the design:content:write scope. (chart fields and video autofill are currently preview features — expect unannounced changes.)

Gotcha: the target design must be a Brand Template (a plain design cannot be autofilled), and every data key must exactly match a named field in that template — unmatched keys are ignored and the template's defaults remain. Image fields take an asset_id (upload first via the asset endpoints), not a URL.

4. Upload an asset

# Binary upload
curl -X POST 'https://api.canva.com/rest/v1/asset-uploads' \
  -H "Authorization: Bearer $CANVA_TOKEN" -H "Content-Type: application/octet-stream" \
  -H 'Asset-Upload-Metadata: {"name_base64":"TXkgVXBsb2Fk"}' \
  --data-binary '@/path/to/file'

# Or from a public URL (30 req/min/user)
curl -X POST 'https://api.canva.com/rest/v1/url-asset-uploads' \
  -H "Authorization: Bearer $CANVA_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"my_asset","url":"https://example.com/image.png"}'

Rate limits

The Connect API returns 429 Too Many Requests when you exceed a per-client-user per-minute budget (plus daily/design throttles on export, and a feature quota on autofill for free/trial users). Current limits (requests/min/user):

Operation Limit
Create design (POST /v1/designs) 20
Create asset upload — binary or URL 30
Poll an asset-upload / url-upload job 180
Create autofill job (POST /v1/autofills) 60
Poll an autofill job 120
List brand templates 120
Create export job (POST /v1/exports) 20
Poll an export job 120

Poll async jobs with exponential backoff (fast enough for good UX, slow enough to stay under the poll budget), and honour Retry-After on 429s.

codeAmani notes

Official docs:

Chrome DevTools Developer Training & Resource Guide

Technology: chrome-devtools · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/chrome-devtools

Insight:

Chrome DevTools is three things at once: a manual UI (the panels you open with F12), a protocol (CDP — everything the UI does, scriptable), and an agent surface (the chrome-devtools-mcp server, which lets Claude Code drive a real browser). The throughline is verify in a real browser instead of trusting the diff. Core Web Vitals are the scoreboard — LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 at the 75th percentile — and the Network panel's throttling is how you prove a page survives a Nairobi 3G connection before you ship it.

 ██████╗██╗  ██╗██████╗  ██████╗ ███╗   ███╗███████╗
██╔════╝██║  ██║██╔══██╗██╔═══██╗████╗ ████║██╔════╝
██║     ███████║██████╔╝██║   ██║██╔████╔██║█████╗
██║     ██╔══██║██╔══██╗██║   ██║██║╚██╔╝██║██╔══╝
╚██████╗██║  ██║██║  ██║╚██████╔╝██║ ╚═╝ ██║███████╗
 ╚═════╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝ ╚═╝     ╚═╝╚══════╝

██████╗ ███████╗██╗   ██╗████████╗ ██████╗  ██████╗ ██╗     ███████╗
██╔══██╗██╔════╝██║   ██║╚══██╔══╝██╔═══██╗██╔═══██╗██║     ██╔════╝
██║  ██║█████╗  ██║   ██║   ██║   ██║   ██║██║   ██║██║     ███████╗
██║  ██║██╔══╝  ╚██╗ ██╔╝   ██║   ██║   ██║██║   ██║██║     ╚════██║
██████╔╝███████╗ ╚████╔╝    ██║   ╚██████╔╝╚██████╔╝███████╗███████║
╚═════╝ ╚══════╝  ╚═══╝     ╚═╝    ╚═════╝  ╚═════╝ ╚══════╝╚══════╝

Chrome DevTools Developer Training & Resource Guide

Focus: Everything a developer can do with Chrome DevTools — every panel, the Chrome DevTools Protocol (CDP), the chrome-devtools-mcp server for Claude Code, Core Web Vitals budgets, and end-to-end testing with Playwright / Puppeteer / Lighthouse CI. Grounded in developer.chrome.com, web.dev, and the official MCP repo; reviewed 2026-08-23.


How to use this guide

DevTools has three faces — learn them in order:

  1. The panels (manual) → Part 1. What you open with F12 / Cmd+Opt+I to inspect, debug, and profile by hand.
  2. The protocol (scriptable) → CDP. Everything the UI does, exposed as a websocket API that Playwright, Puppeteer, Lighthouse, and the MCP all build on.
  3. The agent surface (Claude Code) → the MCP. chrome-devtools-mcp lets Claude drive a real browser to verify your work.

The interactive learn module above this page is a live Core Web Vitals + network-throttle playground — start there to build intuition, then use the panel reference below.


Overview

Chrome DevTools is the browser's built-in inspection, debugging, and profiling suite. Under the UI sits the Chrome DevTools Protocol (CDP) — a JSON-over-websocket API that exposes the same capabilities to automation. chrome-devtools-mcp (by Google, Apache-2.0) wraps a Puppeteer-driven Chrome as an MCP server so a coding agent can navigate, click, screenshot, read the console/network, run Lighthouse, and capture performance traces — the practical way to test in a browser before claiming done.

The three faces of DevTools, and how they connect:

flowchart TB
  DT["Chrome DevTools"] --> UI["Panels (F12)<br/>Elements · Console · Network · Performance"]
  DT --> CDP["Chrome DevTools Protocol"]
  CDP --> AUTO["Playwright · Puppeteer · Lighthouse"]
  CDP --> MCP["chrome-devtools MCP<br/>drives Claude Code"]

Official Documentation

Resource URL
Chrome DevTools docs https://developer.chrome.com/docs/devtools
Chrome DevTools Protocol (CDP) https://chromedevtools.github.io/devtools-protocol/
chrome-devtools-mcp (official) https://github.com/ChromeDevTools/chrome-devtools-mcp
Web Vitals https://web.dev/articles/vitals
Playwright https://playwright.dev/docs/intro
Puppeteer https://pptr.dev
Lighthouse CI https://github.com/GoogleChrome/lighthouse-ci

Part 1 — The DevTools panels

Open DevTools: F12, Cmd+Opt+I (macOS), or Ctrl+Shift+I (Win/Linux). The Command Menu (Cmd/Ctrl+Shift+P) jumps to any panel or action by name — the single most useful shortcut.

Panel What it's for Reach for it when…
Elements Inspect & edit the live DOM + CSS (the Styles pane) Tweaking layout, debugging CSS specificity, checking computed styles
Console Log output + a live JS REPL against the page Reading errors, poking at window/page state, quick scripting
Sources View files, set breakpoints, step through JS, edit + save (Workspaces) Debugging logic, watching variables, blackboxing scripts
Network Every request/response, timing, headers, throttling Checking payload size, caching, CORS, slow-connection behaviour
Performance Record a trace of main-thread work; measures Core Web Vitals Diagnosing jank, long tasks, layout thrash, slow LCP
Memory Heap snapshots + allocation timelines Hunting memory leaks, detached DOM nodes
Application Storage (localStorage, IndexedDB, cookies), Service Workers, Cache, manifest Debugging PWAs, auth tokens, offline behaviour
Lighthouse One-click audit: Performance, Accessibility, Best Practices, SEO, PWA A scored baseline + prioritised fixes
Recorder Record, replay & measure a user flow; export to Puppeteer/Playwright Turning a manual flow into a repeatable test
Security Certificate + mixed-content checks Verifying a page is fully HTTPS
Rendering (drawer) Paint flashing, FPS meter, emulate prefers-color-scheme, CSS media Spotting unnecessary repaints, testing dark mode
Coverage (drawer) Unused JS/CSS per file Trimming dead code from the bundle
Issues / Changes / Animations / Sensors / WebAuthn Aggregated warnings · CSS diff · animation timeline · geolocation/orientation emulation · authenticator emulation Targeted debugging of each concern

Open drawer tools with Esc → the ⋮ menu → More tools, or via the Command Menu.

Elements + Styles — DOM & CSS

Inspect    Cmd/Ctrl+Shift+C, then click an element
Edit DOM   double-click a node / press F2
Force state :hov → toggle :hover/:focus/:active to debug states
Color      click a swatch in Styles for the eyedropper + contrast ratio

The contrast ratio readout in the color picker is your fastest accessibility check — it flags AA/AAA pass/fail inline.

Console — REPL + logging

// $0 is the currently-selected Elements node; $$ is querySelectorAll
$0.getBoundingClientRect();
$$('img:not([alt])');                 // find images missing alt text
monitorEvents($0, 'click');           // log events on an element
copy(JSON.stringify(window.__DATA__)); // copy a value to clipboard
console.table(performance.getEntriesByType('resource')); // request table

Sources — the debugger

Breakpoint        click a line number
Conditional bp    right-click a line → "Add conditional breakpoint"
Logpoint          inject a console.log without editing source
DOM breakpoint    Elements → right-click → "Break on" → subtree/attribute change
Step              F10 over · F11 into · Shift+F11 out · F8 resume

Network — payload & throttling

Throttling presets (Chrome 127+):  Offline · 3G · Slow 4G · Fast 4G · Custom
  (formerly "Slow 3G"/"Fast 3G"; DevTools no longer prints exact kbps —
   the presets are tuned to match real-world conditions.)
Disable cache     check the box (while DevTools is open) to test cold loads
Filter            `larger-than:500k`, `-domain:*.google.com`, `mixed-content:all`
Copy as cURL      right-click a request → reproduce it from the terminal

codeAmani habit: test every page under 3G (the old "Slow 3G") with Disable cache on. If LCP blows past 2.5s, the bundle is too heavy for the median Kenyan connection — lazy-load and split before shipping.

Performance — traces & Core Web Vitals

Record       click ● (or Cmd/Ctrl+E) → interact → stop
Reload trace click ⟳ to capture a full page load
Read it      Main track = JS/layout/paint; red-cornered bars = long tasks (>50ms)
CWV          LCP/CLS/INP markers overlay the timeline

Application — storage & PWAs

Service Workers   update-on-reload, bypass-for-network, push test
Storage           localStorage / sessionStorage / IndexedDB / Cookies (edit inline)
Clear storage     one button to reset to a first-visit state
Manifest          install + icon/colour validation for PWAs

Part 2 — Core Web Vitals & performance budgets

The scoreboard for real-user performance. Thresholds are measured at the 75th percentile of page loads, segmented by mobile/desktop (source: web.dev/vitals).

Metric Measures Good Needs improvement Poor
LCP — Largest Contentful Paint Loading ≤ 2.5 s 2.5 – 4.0 s > 4.0 s
INP — Interaction to Next Paint Interactivity ≤ 200 ms 200 – 500 ms > 500 ms
CLS — Cumulative Layout Shift Visual stability ≤ 0.1 0.1 – 0.25 > 0.25

INP replaced FID as a Core Web Vital in 2024 — it measures the latency of all interactions, not just the first.

Supporting diagnostics:

Metric Good threshold Helps explain
FCP — First Contentful Paint ≤ 1.8 s LCP
TTFB — Time to First Byte ≤ 0.8 s FCP / LCP

Measure vitals in code

npm install web-vitals
import { onLCP, onINP, onCLS } from "web-vitals";

// Field data — report from real users to your analytics endpoint
onLCP((m) => navigator.sendBeacon("/vitals", JSON.stringify(m)));
onINP((m) => navigator.sendBeacon("/vitals", JSON.stringify(m)));
onCLS((m) => navigator.sendBeacon("/vitals", JSON.stringify(m)));

Lab vs field: DevTools / Lighthouse give you lab data (one synthetic run). web-vitals + analytics give you field data (real users, the 75th-percentile that actually counts). Optimise in the lab; verify in the field.


Part 3 — Chrome DevTools Protocol (CDP)

Everything the UI does is a CDP command. Playwright, Puppeteer, Lighthouse, and the MCP all speak it. Start Chrome with a debugging port and you can drive it from anything:

# Launch Chrome with the protocol exposed
google-chrome --remote-debugging-port=9222 --headless=new

# List targets (tabs) — each has a websocket debugger URL
curl http://localhost:9222/json
// Raw CDP via Puppeteer's session — e.g. emulate a slow network
const client = await page.createCDPSession();
await client.send("Network.emulateNetworkConditions", {
  offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8,
  uploadThroughput: (750 * 1024) / 8, latency: 150,
});
await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }); // 4× slowdown

Domains you'll use most: Page, Network, Runtime, Performance, Emulation, DOM, Accessibility. Full reference: chromedevtools.github.io/devtools-protocol.


Part 4 — chrome-devtools MCP for Claude Code

The official chrome-devtools-mcp server (Google LLC, Apache-2.0; 1.7.0 as of this review) wraps a Puppeteer-controlled Chrome as MCP tools, so Claude Code can drive a real browser.

Setup

# Add via the Claude Code CLI
claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest
// .mcp.json
{
  "mcpServers": {
    "chrome-devtools": {
      "command": "npx",
      "args": ["-y", "chrome-devtools-mcp@latest"],
      "env": {
        "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
      }
    }
  }
}

Useful flags (append to args after chrome-devtools-mcp@latest): --headless (no visible window — for CI), --isolated (throwaway profile, auto-cleaned), --channel stable|beta|dev|canary, --executablePath <path> or --browserUrl http://127.0.0.1:9222 to attach to an already-running Chrome, and --slim for a reduced toolset when the full surface is more than a task needs.

Available MCP tools

1.7.0 exposes ~57 tools; the ~44 below load by default, grouped by what they do (names as exposed by the server):

Group Tools
Navigation / pages (6) new_page · navigate_page · list_pages · select_page · close_page · wait_for
Interaction (10) click · click_at · hover · drag · fill · fill_form · type_text · press_key · upload_file · handle_dialog
Inspect / capture (8) take_snapshot (accessibility tree) · take_screenshot · evaluate_script · list_console_messages · get_console_message · lighthouse_audit · screencast_start · screencast_stop
Network (2) list_network_requests · get_network_request
Performance (3) performance_start_trace · performance_stop_trace · performance_analyze_insight
Emulation (2) emulate (viewport · networkConditions · cpuThrottlingRate · geolocation · colorScheme · userAgent) · resize_page
Memory (13) take_heapsnapshot + the heap-snapshot analysis family (compare_heapsnapshots · get_heapsnapshot_dominators/retainers/summary/… · query_heapsnapshot_objects · close_heapsnapshot)

Four further categories are opt-in behind flags — Extensions (--categoryExtensions), PWA (--categoryPwa), plus experimental third-party and WebMCP tool bridges; screencast_* needs --experimentalScreencast=true.

take_snapshot returns the accessibility tree — both your a11y check and the most reliable way for the agent to locate elements (by role/name) instead of brittle CSS selectors. Tool names and grouping change across releases — confirm the live list any time with /mcp in Claude Code.


Part 5 — Claude Code commands, hooks & testing workflows

Slash command: visual + Lighthouse review

.claude/commands/screenshot.md:

Open $ARGUMENTS in a browser and review it.

1. `new_page` then `navigate_page` to $ARGUMENTS
2. `resize_page` to 1280×900, then `take_screenshot`
3. `emulate` a "Slow 4G" network and reload; `take_screenshot` again
4. `lighthouse_audit` for performance + accessibility scores
5. `list_console_messages` for errors
Report: layout/contrast issues, the two screenshots' differences, Lighthouse
scores vs the budgets (LCP ≤ 2.5s, CLS ≤ 0.1), and console errors with fixes.

Usage: /project:screenshot http://localhost:3000/dashboard

Hook: nudge a browser check after a UI change

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE '\\.(tsx|jsx|css)$'; then echo 'UI file changed — verify with the chrome-devtools MCP before claiming done'; fi" }
        ]
      }
    ]
  }
}

Playwright — E2E tests

npm init playwright@latest
npx playwright test --ui        # interactive runner
npx playwright codegen <url>    # record a test by clicking
npx playwright show-trace trace.zip
// tests/auth.spec.ts
import { test, expect } from "@playwright/test";

test("user can sign in", async ({ page }) => {
  await page.goto("/login");
  await page.getByTestId("email").fill("test@example.com");
  await page.getByTestId("password").fill("secret123");
  await page.getByTestId("submit").click();
  await expect(page).toHaveURL("/dashboard");
  await expect(page.getByRole("heading", { level: 1 })).toContainText("Welcome");
});

test("renders within a 3G budget", async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send("Network.emulateNetworkConditions", {
    offline: false, latency: 300,
    downloadThroughput: (400 * 1024) / 8, uploadThroughput: (400 * 1024) / 8,
  });
  const t0 = Date.now();
  await page.goto("/", { waitUntil: "load" });
  expect(Date.now() - t0).toBeLessThan(8000); // hard ceiling on a slow link
});

Puppeteer — scripted browser

import puppeteer from "puppeteer";

// headless: true IS the new headless mode now (Puppeteer 22+); the old
// headless: "new" string is deprecated — use `"shell"` for the legacy mode.
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.emulateNetworkConditions(puppeteer.networkConditions["Slow 3G"]);
await page.goto("https://example.com", { waitUntil: "networkidle0" });
await page.screenshot({ path: "shot.png", fullPage: true });
await browser.close();

Lighthouse CI — budgets in CI

# .github/workflows/lighthouse.yml
name: Lighthouse
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npm run build
      - run: npm install -g @lhci/cli && lhci autorun
        env: { LHCI_GITHUB_APP_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
// lighthouserc.js — fail the PR if budgets regress
module.exports = {
  ci: {
    collect: { startServerCommand: "npm run preview", url: ["http://localhost:4173/"], numberOfRuns: 3 },
    assert: { assertions: {
      "categories:performance": ["warn", { minScore: 0.8 }],
      "categories:accessibility": ["error", { minScore: 0.9 }],
      "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
      "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
    } },
    upload: { target: "temporary-public-storage" },
  },
};

Common use cases

Goal Fastest path
Verify a UI change chrome-devtools MCP navigate_page + take_screenshot
Accessibility audit take_snapshot (a11y tree) or lighthouse_audit
Check the slow-connection budget Network panel 3G + Disable cache, or emulate in the MCP
Find a memory leak Memory panel heap snapshots over time / take_heapsnapshot
Diagnose jank Performance trace → look for long tasks (>50ms)
Reproduce an API call Network → right-click → Copy as cURL
Record a test Recorder panel → export to Playwright, or playwright codegen
Trim the bundle Coverage drawer → delete unused JS/CSS

Troubleshooting

Issue Fix
Chrome not found (MCP) Set CHROME_PATH to your Chrome/Chromium binary
Screenshot blank Wait for load: MCP wait_for, or Playwright waitForLoadState("networkidle")
CDP connection refused Launch Chrome with --remote-debugging-port=9222
Playwright browser missing npx playwright install chromium
Lighthouse times out Raise --max-wait-for-load, or reduce CPU throttling
MCP server won't connect Ensure Chrome can launch headlessly; check npx -y chrome-devtools-mcp@latest runs
Throttling preset looks different Chrome 127 renamed them: "Slow 3G"→"3G", "Fast 3G"→"Slow 4G", + "Fast 4G"
INP shows "—" INP needs a real interaction; click/scroll, then re-measure

codeAmani notes

Official docs:

Claude Design Integration Guide

Technology: claude-design · Category: design · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/claude-design

Insight:

Claude Design is a canvas, not an API — an Anthropic Labs research preview where Claude drafts a multi-artboard visual design you then refine by hand and export or hand off to code. There is no SDK and no public endpoint: you reach it at claude.ai/design or through the /design skill in Claude Code, which rides on Artifacts. For codeAmani it is the fastest brief→shareable-mockup path; Figma still owns production design, and nothing sensitive belongs on an artboard.

 ██████╗██╗      █████╗ ██╗   ██╗██████╗ ███████╗
██╔════╝██║     ██╔══██╗██║   ██║██╔══██╗██╔════╝
██║     ██║     ███████║██║   ██║██║  ██║█████╗
██║     ██║     ██╔══██║██║   ██║██║  ██║██╔══╝
╚██████╗███████╗██║  ██║╚██████╔╝██████╔╝███████╗
 ╚═════╝╚══════╝╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝

██████╗ ███████╗███████╗██╗ ██████╗ ███╗   ██╗
██╔══██╗██╔════╝██╔════╝██║██╔════╝ ████╗  ██║
██║  ██║█████╗  ███████╗██║██║  ███╗██╔██╗ ██║
██║  ██║██╔══╝  ╚════██║██║██║   ██║██║╚██╗██║
██████╔╝███████╗███████║██║╚██████╔╝██║ ╚████║
╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝  ╚═══╝

Claude Design Integration Guide

Focus: Getting a multi-artboard visual design out of Claude — UI mockups, screen flows, landing pages, posters and one-pagers — from claude.ai/design or the /design skill in Claude Code, then exporting it or handing it back to code.

Overview

Claude Design is Anthropic Labs' visual workspace: you describe what you want, Claude drafts it onto a canvas of artboards, and you refine it by talking, commenting, or editing directly on the canvas. It launched 17 April 2026 as a research preview on the Pro, Max, Team, and Enterprise plans, powered by Claude Opus 4.7, and lives at claude.ai/design and in the Claude Desktop sidebar.

The important structural fact: designs are generated as code, not pixels. Every artboard is a rendered HTML document. That is what makes the export and the handoff-to-Claude-Code path work at all — and it is also why the whole feature has no public API, no npm package, and no REST endpoint. You reach it through the product surfaces, not a client library.

There are two surfaces, and they are not the same product:

Claude Design (claude.ai/design) /design skill (Claude Code)
Status Research preview / beta (Anthropic Labs) Research preview, shipped in Claude Code v2.1.233+ (Week 34, Aug 17–21 2026)
Where Web + Claude Desktop sidebar Claude Code CLI + Claude Code Desktop
Built on Its own project/canvas workspace Artifacts — the canvas is published as an artifact page
Projects & history Yes — projects, attached design systems, comments Per-session; the artifact URL is the handle
Design systems /design-sync-uploaded or imported systems Whatever it can infer + your CLAUDE.md design tokens
Export ZIP, PDF, PPTX, standalone HTML, partner sends, Handoff to Claude Code View + export from the published canvas page
Plans Pro, Max, Team, Enterprise (Enterprise: opt in) Pro, Max, Team, Enterprise (artifacts must be enabled)
flowchart LR
  A["Brief<br/>'a few options for the rider check-in screen'"] --> B{"Which surface?"}
  B -->|"claude.ai/design"| C["Project canvas<br/>artboards + comments<br/>+ attached design system"]
  B -->|"/design in Claude Code"| D["Artboards drafted in-session<br/>published as an Artifact"]
  C --> E["Refine: chat · inline comments<br/>· direct canvas edits"]
  D --> E
  E --> F{"Ship it how?"}
  F -->|"stakeholders"| G["Export: PDF · PPTX · ZIP<br/>· standalone HTML"]
  F -->|"engineering"| H["Handoff bundle → Claude Code<br/>components · tokens · layout"]
  F -->|"production design"| I["Figma<br/>(codeAmani's system of record)"]

Official Documentation

Resource URL
Launch announcement (Anthropic Labs) https://www.anthropic.com/news/claude-design-anthropic-labs
Get started with Claude Design https://support.claude.com/en/articles/14604416-get-started-with-claude-design
Admin guide (Team / Enterprise) https://support.claude.com/en/articles/14604406-claude-design-admin-guide-for-team-and-enterprise-plans
Artifacts in Claude Code (what /design is built on) https://code.claude.com/docs/en/artifacts
Commands reference (/design-sync, /design-login, /dataviz) https://code.claude.com/docs/en/commands
/design release note https://code.claude.com/docs/en/whats-new/2026-w34
Artifacts in the Claude apps https://support.claude.com/en/articles/9487310-what-are-artifacts-and-how-do-i-use-them

Setup

There is nothing to install for the web surface — sign in at claude.ai/design. For the /design skill you need a current Claude Code signed in with a claude.ai subscription (not an API key):

# Claude Code v2.1.233 or later is required for /design
npm install -g @anthropic-ai/claude-code
claude --version
# /design and artifacts both require a subscription-backed session
claude
> /login
> /design a few options for the rider check-in screen

Claude drafts the artboards, publishes the canvas, and prints a link. Open it, pick an artboard, and tell Claude which option to implement.

Environment variables

These are the artifact switches — /design inherits every one of them, because the canvas is published as an artifact.

# Turn artifacts (and therefore /design's canvas) off for your own sessions
CLAUDE_CODE_DISABLE_ARTIFACT=1

# Stop the browser opening automatically on publish
CLAUDE_CODE_ARTIFACT_AUTO_OPEN=0

# Needed only if you disabled feature-flag fetching and still want comments
CLAUDE_CODE_ARTIFACT_COMMENTS=1
CLAUDE_CODE_ARTIFACT_COMMENTS_AUTOREACT=1

Equivalent settings-file form:

{
  "disableArtifact": true
}

Key patterns

1. Ask for options, not a design

The whole point of a multi-artboard canvas is comparison. A brief that names a count and an axis of variation gets you something to choose between; a brief that says "design the settings page" gets you one guess.

/design four takes on the boda rider check-in screen — vary how much the map
dominates and whether the fare quote is a card or an inline row. One line under
each on the trade-off.

The same instinct works for an artifact without /design at all:

Make an artifact with four distinctly different layouts for the settings panel.
Vary density and grouping, and lay them out as a grid with a one-line tradeoff
under each.

2. Give it your design system before it invents one

Claude applies a built-in design skill to every artifact it builds, and that skill looks for an existing design system in your project first. Record your tokens where Claude will find them — CLAUDE.md or a theme file — and they take precedence over Claude's own choices (your prompt beats both).

## Design system

- Colors: primary #00d4ff, secondary #3ecf8e, tertiary #8b5cf6, surface #080b0f
- Typography: Outfit for display and body, JetBrains Mono for code and labels
- Spacing: 8px scale, 14px panel radius
- Panels: rgba(20,26,36,0.55) fill, 1px rgba(255,255,255,0.06) border, blur(14px)

That block is codeAmani's MotionStack Dark system (see anthropic/claude_dev_guide_reference.md) — drop it in a project's CLAUDE.md and every artboard and artifact that project produces comes out on-brand instead of generic-SaaS-purple.

Only Google Fonts loads from outside a published page. Any other typeface has to be inlined as a @font-face data URI, so pick a Google-hosted face (Outfit and JetBrains Mono both are) or accept the fallback stack.

3. /design-sync — push your real React components up

For teams that want the canvas building with actual components rather than lookalikes, the commands reference documents a pair:

claude
> /design-login              # authorize design-system access with your claude.ai account
> /design-sync codeAmani DS  # convert this repo's React design system and upload it

Caveats straight from the docs: a first-time sync verifies every component and can take a few hours on a large repo, and it is Anthropic-API only — on Amazon Bedrock, Google Cloud's Agent Platform, Microsoft Foundry, and Claude Platform on AWS the underlying tool cannot reach claude.ai, so the command is unavailable.

4. Refine on the canvas, not in the prompt

Three refinement channels, and they are not interchangeable:

Prompting for a 4px spacing change is a waste of a turn. Do structure by chat and polish by hand.

5. Export and handoff

From the Export control on a Claude Design project:

Path Use it for
Download as ZIP Archiving the whole project
Export as PDF Stakeholder review, print, email attachment
Export as PPTX Decks that someone else has to keep editing
Export as standalone HTML Self-hosting an interactive prototype — one file, assets inlined
Send to partners Adobe, Canva, Vercel, Wix and others
Handoff to Claude Code Building it for real

The handoff bundle is the interesting one: it carries the component structure as a machine-readable spec plus the tokens actually used on the canvas, so Claude Code is reading a spec rather than inferring intent from a screenshot.

6. Updating a canvas later

A /design canvas is an artifact, so the artifact rules apply. From a new session, give Claude the URL or attach it with /artifacts — otherwise Claude creates a new canvas instead of updating yours.

Update https://claude.ai/code/artifact/5fbea6f3-... — swap artboard 2's fare card
for the inline row treatment from artboard 4 and republish.

/artifacts lists everything you own or have been shared; o opens, c copies the link, Enter attaches it to the session. Ctrl+] reopens the most recent artifact from the terminal.


Where it fits next to Figma and Canva

codeAmani already runs figma/ and canva/ guides. They do not overlap as much as they look:

Need Reach for
First draft, exploration, "show me four ways this could go" Claude Design
Production design system, components, variants, tokens, real handoff Figma (figma/CLAUDE_CODE_INTEGRATION.md)
Mass-producing on-brand graphics from data (Brand Template + autofill) Canva Connect API (canva/CLAUDE_CODE_INTEGRATION.md)
Reading an existing design into code Figma MCP get_design_context
A one-pager or poster nobody will maintain Claude Design, export PDF, done

The honest boundary: Claude Design collapses the blank-canvas problem and the "I need something to react to by Thursday" problem. It does not replace a maintained component library, and it has no version-controlled source of truth the way a Figma library does. Draft in Claude Design, decide, then rebuild the survivor in Figma or straight in code.


Constraints worth knowing before you promise something

Because the Claude Code canvas is an artifact, the artifact page constraints are the canvas constraints:

Constraint Effect
External requests CSP blocks scripts, styles, fonts, and images from other hosts, plus fetch/XHR/WebSocket. Google Fonts is the one exception; everything else is inlined or a data URI
No backend Static page. It cannot store form input or authenticate viewers
Single page Relative links do not resolve — in-page anchors only
File types Published file must be .html, .htm, or .md
Rendered size 16 MiB max. Large embedded raster images are the usual cause of a failed publish
Auth Session must be signed in with /login. API-key, LLM-gateway, and cloud-provider-credential sessions cannot publish
Model provider Anthropic API only — not Bedrock, Google Cloud's Agent Platform, or Microsoft Foundry
Org policy Blocked when CMEK, HIPAA, or Zero Data Retention are enabled for the org

And from the Claude Design admin guide: it is web-only today, there is no data-residency support, audit logs are not supported yet, and preview access is gated by short-lived signed tokens re-checked against sharing permissions on every open. Enterprise admins enable it under Organization settings > Capabilities > Anthropic Labs > Claude Design (default off), and a Claude Design Admin permission controls who may publish, default, or delete a design system.


codeAmani notes


Troubleshooting

Issue Fix
/design is not in the command menu Needs Claude Code v2.1.233+; upgrade with npm install -g @anthropic-ai/claude-code. Unavailable commands are omitted from the menu entirely
Claude writes a local HTML file and no link The artifact tool is not enabled for the session — check plan, /login, model provider, and org policy against the availability table above
"Cannot publish" on a Bedrock / Vertex / Foundry session Artifacts and /design-sync are Anthropic-API only. Use a subscription-backed session
A new session created a second canvas instead of updating mine Pass the artifact URL in the prompt, or attach it first with /artifacts
Publish fails for size Rendered page must be ≤ 16 MiB — the cause is nearly always embedded raster images; swap to SVG
Fonts render wrong for viewers Only fonts.googleapis.com / fonts.gstatic.com load externally. Everything else must be an inlined @font-face data URI, and every face needs a fallback stack
Comments missing on a shared canvas Comments require sharing within your organization (Team/Enterprise). A publicly shared artifact reports Comments aren't available while this Artifact is shared publicly.
/design-sync seems stuck A first sync verifies every component and can take hours on a large repo — expected, not hung
Enterprise users can't see Claude Design Default off on Enterprise. Owner enables it under Organization settings > Capabilities > Anthropic Labs; access changes take up to 15 minutes

Official docs:

Claude Skills Integration Guide

Technology: claude-skills · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/claude-skills

Insight:

An Agent Skill is a SKILL.md folder Claude loads on demand — a tiny always-on description advertises it (~100 tokens) and the full body plus bundled scripts load only when a request matches. It is a file format, not a library (nothing to npm install): the same folder runs in Claude Code, the Agent SDK, the Claude API (/v1/skills, now GA — no beta header), and claude.ai, and Claude Code now follows the open Agent Skills standard (agentskills.io), with custom /commands folded into skills. For codeAmani, skills bank fiddly house conventions (M-Pesa phone normalisation, integer KES, callback idempotency) once and reuse them everywhere — the trade-off is that a skill runs code with your permissions, so audit every third-party one.

 ██████╗██╗      █████╗ ██╗   ██╗██████╗ ███████╗    ███████╗██╗  ██╗██╗██╗     ██╗     ███████╗
██╔════╝██║     ██╔══██╗██║   ██║██╔══██╗██╔════╝    ██╔════╝██║ ██╔╝██║██║     ██║     ██╔════╝
██║     ██║     ███████║██║   ██║██║  ██║█████╗      ███████╗█████╔╝ ██║██║     ██║     ███████╗
██║     ██║     ██╔══██║██║   ██║██║  ██║██╔══╝      ╚════██║██╔═██╗ ██║██║     ██║     ╚════██║
╚██████╗███████╗██║  ██║╚██████╔╝██████╔╝███████╗    ███████║██║  ██╗██║███████╗███████╗███████║
 ╚═════╝╚══════╝╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝    ╚══════╝╚═╝  ╚═╝╚═╝╚══════╝╚══════╝╚══════╝

Claude Skills Integration Guide

Focus: how to author an Agent Skill — a SKILL.md folder Claude loads on demand. The whole system is one idea: a tiny always-loaded description advertises the skill, and the full instructions (plus any scripts and reference files) load only when a request matches. This is the Agent Skills format, distinct from building an agent loop — here you are packaging reusable expertise, not wiring tools into a runtime.

Overview

A Skill is a folder containing a SKILL.md file: YAML frontmatter (name + description) followed by markdown instructions. Optionally it bundles extra markdown reference files and executable scripts. Claude discovers skills automatically and pulls each one into context only when relevant — so you can install dozens of skills for roughly 100 tokens each until one actually fires.

The mechanism that makes this cheap is progressive disclosure, three levels of loading:

  1. Metadata (always loaded) — the name and description from every skill's frontmatter sit in the system prompt. This is all Claude knows by default: that the skill exists and when to use it.
  2. Instructions (loaded when triggered) — when a request matches a skill's description, Claude reads the SKILL.md body off the filesystem (via bash). Only now do the procedures enter context. Keep this under ~500 lines.
  3. Resources (loaded as needed) — bundled reference files (REFERENCE.md, EXAMPLES.md) are read only when the body points to them; bundled scripts are executed, never read into context (only their output costs tokens). There is no practical limit on bundled content because it costs zero until accessed.

The single highest-leverage thing you write is the description. Claude uses it to choose among potentially 100+ skills, so it must say both what the skill does and when to reach for it — phrased in the third person with the trigger words a user would actually type.

Official Documentation

Source URL What it covers
Agent Skills overview https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview What a Skill is, frontmatter fields, the 3 progressive-disclosure levels, where Skills run
Authoring best practices https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices Writing descriptions, degrees of freedom, bundling scripts, anti-patterns, the authoring checklist
Skills in Claude Code https://code.claude.com/docs/en/skills .claude/skills/ discovery, full frontmatter reference (allowed-tools, disable-model-invocation, model, paths, …), $ARGUMENTS, dynamic context
Skills in the Agent SDK https://code.claude.com/docs/en/agent-sdk/skills The skills option ("all" / list / []), settingSources loading, dispatch by /name, confirming load via the init message
Use Skills with the API https://platform.claude.com/docs/en/build-with-claude/skills-guide The /v1/skills endpoints, the container.skills + code-execution wiring (GA — no beta header)
Agent Skills standard https://agentskills.io The open cross-tool spec Claude Code now follows; the six portable frontmatter fields

No package to install. Agent Skills are a file format, not a library — there is nothing to npm install to author one. Claude Code, the Claude Agent SDK, claude.ai, and the Claude API each read SKILL.md natively, and the format is now an open cross-tool standard (agentskills.io). In Claude Code, custom /commands have merged into skills: a legacy .claude/commands/deploy.md and a .claude/skills/deploy/SKILL.md both create /deploy and behave the same way (skills just add a folder for supporting files, richer frontmatter, and automatic model invocation). Loading skills from your own agent runtime is the Claude Agent SDK — see How the Agent SDK loads skills below and the ai-agents guide.


Frontmatter fields

Every SKILL.md opens with YAML between --- markers. Only description is strictly needed for Claude to know when to use a skill; every other field is optional. Six fields are part of the portable Agent Skills standard and work everywhere; the rest are Claude Code extensions.

Field Where Notes
name portable Lowercase letters, numbers, hyphens only. Max 64 chars. Cannot contain anthropic, claude, or XML tags (reserved). In Claude Code name is optional — it defaults to the directory name and only sets the display label; the slash command always comes from the directory. On claude.ai / the API it identifies the skill.
description portable Third person, what it does + when to use it — this is the trigger. Max 1024 chars in the portable spec; Claude Code truncates the combined description + when_to_use at 1,536 chars in the skill listing, so put the key use case first.
allowed-tools portable Tools pre-approved (no per-use prompt) for the turn that invokes the skill — the grant clears on your next message. Space- or comma-separated, or a YAML list, e.g. Bash(git add *) Bash(git commit *). Grants, never restricts the pool.
license / metadata / compatibility portable Spec metadata: SPDX license; a free-form metadata map for your own tooling; a compatibility string (≤500 chars). Claude Code accepts but doesn't act on them.
disable-model-invocation Claude Code true = only the human can run it via /name, and its description leaves Claude's context (use for side-effecting workflows like deploy/commit).
user-invocable Claude Code false = only Claude can load it (background knowledge, hidden from the / menu).
context: fork + agent Claude Code context: fork runs the skill in an isolated subagent (background by default; set background: false to block the turn). The separate agent: field picks the subagent type (Explore, Plan, general-purpose, or a custom one).
disallowed-tools Claude Code Tools removed from the pool while the skill is active (e.g. block AskUserQuestion in a background loop). Clears on your next message.
model / effort Claude Code Override the model / effort level for the turn the skill is active (inherit keeps the current model).
paths Claude Code Glob patterns that gate auto-activation — Claude loads the skill only when working on matching files.
arguments / argument-hint Claude Code Declare named positional args for $name substitution and an autocomplete hint. $ARGUMENTS, $0, $1 work without declaration.
hooks / shell Claude Code Register hooks for the session when the skill fires; shell: powershell runs !`command` injection via PowerShell instead of bash.

Portable six. Outside Claude Code — claude.ai uploads, the Skills API, and package_skill.py from anthropics/skills — only name, description, license, compatibility, metadata, and allowed-tools are accepted; any Claude Code-only field makes packaging/upload hard-fail with an "unexpected key" error. Keep a skill you intend to publish to those surfaces on the portable six.

The reserved-word rule matters here: a skill about Claude Skills themselves cannot be named claude-skills. Name it after the activity instead — see the worked example below.


Build your first skill — a worked example

We'll build a real codeAmani-relevant skill: normalising Kenyan phone numbers to the 2547XXXXXXXX format Daraja requires. This is a perfect skill candidate — it's a fiddly, deterministic rule you'd otherwise paste into chat every time, and it has a clear "when to use" trigger.

(a) Directory layout

A skill is a directory; SKILL.md is the entrypoint. We bundle one helper script and one reference file to demonstrate progressive disclosure:

.claude/skills/normalising-mpesa-phones/
├── SKILL.md                 # always-discoverable metadata + concise instructions
├── REFERENCE.md             # the full edge-case table (loaded only when needed)
└── scripts/
    └── normalise.py         # deterministic normaliser (executed, never read)

Use gerund-form names (normalising-mpesa-phones), forward slashes always, and descriptive filenames — never doc2.md.

(b) SKILL.md frontmatter — the description is the trigger

The description is the only thing loaded until the skill fires, so phrase it with when-to-use cues (the words a teammate would actually say):

---
name: normalising-mpesa-phones
description: >-
  Normalises Kenyan phone numbers to the 2547XXXXXXXX / 2541XXXXXXXX format the
  Daraja (M-Pesa) API requires. Use when formatting a phone number for an STK Push,
  a B2C payout, an SMS, or whenever a number arrives as 07.., +2547.., or 2547...
allowed-tools: Bash(python3 *)
---

Compare a bad description — description: Helps with phone numbers — which gives Claude no trigger to match on and no idea what "help" means. Be specific; include key terms (Daraja, STK Push, 07.., +254).

(c) The body — concise instructions

Assume Claude is already smart. State the rule and the canonical path; don't explain what a phone number is:

# Normalising M-Pesa phone numbers

Daraja rejects anything that is not `254` followed by 9 digits (e.g. `254712345678`).
Normalise every number to that shape **before** calling any Daraja endpoint.

## Rules
- Strip spaces, hyphens, and a leading `+`.
- `07XXXXXXXX` or `01XXXXXXXX` → replace the leading `0` with `254`.
- `7XXXXXXXX` / `1XXXXXXXX` (9 digits, no prefix) → prepend `254`.
- Already `2547…` / `2541…` (12 digits) → leave as-is.
- Anything else → reject; do not guess.

## Use the bundled script (preferred — deterministic)
Run it rather than reimplementing the rule:

    python3 ${CLAUDE_SKILL_DIR}/scripts/normalise.py "0712 345 678"
    # → 254712345678

For the full edge-case table (Safaricom vs Airtel prefixes, invalid lengths),
see [REFERENCE.md](REFERENCE.md).

Note the two progressive-disclosure links: REFERENCE.md is read only if Claude needs the edge cases, and normalise.py is executed (its source never enters context). Keep references one level deep — link every supporting file directly from SKILL.md, never a chain of a.md → b.md → c.md.

(d) The bundled script — deterministic, self-contained

A pre-made script is more reliable than asking Claude to regenerate the regex each time, and it costs zero context until run:

#!/usr/bin/env python3
"""Normalise a Kenyan phone number to Daraja's 2547XXXXXXXX format."""
import re, sys

def normalise(raw: str) -> str:
    s = re.sub(r"[\s\-]", "", raw).lstrip("+")
    if re.fullmatch(r"0[17]\d{8}", s):      # 07.. / 01..
        return "254" + s[1:]
    if re.fullmatch(r"[17]\d{8}", s):       # bare 9-digit
        return "254" + s
    if re.fullmatch(r"254[17]\d{8}", s):    # already canonical
        return s
    raise ValueError(f"Not a valid Kenyan mobile number: {raw!r}")

if __name__ == "__main__":
    print(normalise(sys.argv[1]))

(e) allowed-tools — pre-approve just enough

The frontmatter line allowed-tools: Bash(python3 *) lets Claude run the helper without a permission prompt during the turn that fires the skill, while leaving every other tool governed by your normal permission settings. The grant is turn-scoped — it clears when you send your next message, then re-applies each time the skill is invoked again. Grant narrowly — Bash(python3 *), not bare Bash — and use disallowed-tools to remove a tool from the pool for a locked-down skill. For a side-effecting skill (deploy, send money) add disable-model-invocation: true so only a human can fire it. (In an Agent SDK session this frontmatter field is ignored for project/personal skills — pre-approve via the SDK's allowedTools option instead.)

(f) Where it lives and how it's discovered

The same SKILL.md works across every surface; only the install path changes:

Surface How to install Sharing scope
Claude Code (personal) ~/.claude/skills/<name>/SKILL.md all your projects
Claude Code (project) .claude/skills/<name>/SKILL.md (commit it) this repo (loads from cwd + every parent to the repo root)
Claude Code (enterprise) .claude/skills/<name>/ in the managed-settings directory every user in the org; overrides personal and project
Claude Code (plugin) <plugin>/skills/<name>/SKILL.md wherever the plugin is enabled; namespaced plugin:name
Claude Agent SDK the same filesystem folders, gated by settingSources + the skills option whatever the SDK session's setting sources load
Claude API upload via the /v1/skills endpoints, then reference the skill in container.skills (type/skill_id/version) alongside the code-execution tool workspace-wide
claude.ai upload a .zip under Settings → Features (code execution must be enabled) per-user only

In Claude Code the directory name becomes the slash command (/normalising-mpesa-phones) and the project skill is picked up automatically from .claude/skills/ in the cwd and every parent up to the repo root. A same-named skill at a higher level wins (enterprise > personal > project) and also overrides a bundled skill of that name. Note that a project skill's allowed-tools grant is not gated by workspace trust — Claude Code applies it even in an untrusted -p run — so review the allowed-tools of any skill checked into a repo before running Claude Code there. (Adding a .claude-plugin/plugin.json to a skill folder does require accepting the workspace-trust dialog first.) Custom Skills do not sync across surfaces — a skill uploaded to the API is not on claude.ai, and Claude Code skills are filesystem-only; you can optionally pull skills you enabled on claude.ai into ~/.claude/skills/synced/ with CLAUDE_CODE_SYNC_SKILLS=1.


How progressive disclosure loads a skill

flowchart TD
  M["Level 1 · Metadata<br/>name + description<br/>(always in system prompt, ~100 tok)"] --> B["Level 2 · SKILL.md body<br/>read via bash when triggered<br/>(under ~5k tok)"]
  B --> R["Level 3 · REFERENCE.md / EXAMPLES.md<br/>read only if the body points to them"]
  B --> S["Level 3 · scripts/normalise.py<br/>EXECUTED via bash · source never loaded<br/>(only stdout costs tokens)"]

The cost ladder is the whole point: thousands of words of edge-case docs and a dozen scripts sit on disk at zero token cost until the one file a task needs is actually opened. One nuance to design around: once the body loads it stays in context for the rest of the session (Claude Code doesn't re-read the file each turn), so write it as standing guidance, keep it lean, and expect a large skill to be trimmed by auto-compaction. The allowed-tools grant is the exception — that resets every message.

How Claude decides to load a skill

flowchart TD
  U["User request arrives"] --> C{"Does any skill's<br/>description match?"}
  C -->|"no"| N["Answer normally · no skill loaded"]
  C -->|"yes"| I{"disable-model-invocation?"}
  I -->|"true"| H["Only a human /command can run it"]
  I -->|"false / unset"| L["Read SKILL.md body into context"]
  L --> D{"Body references<br/>a resource or script?"}
  D -->|"reference file"| RF["bash read just that file"]
  D -->|"script"| EX["bash execute · capture output only"]
  D -->|"no"| W["Do the work"]

If a skill never triggers, the fix is almost always the description: add the keywords users actually say. If it triggers too eagerly, make the description more specific or set disable-model-invocation: true.


How the Agent SDK loads skills

The Claude Agent SDK reads the same filesystem skills as the CLI — there is no programmatic registration API for skills (unlike subagents, which you can define inline via the agents option). Two knobs govern them:

const options = {
  cwd: process.cwd(),                    // .claude/skills/ here or in a parent
  settingSources: ["user", "project"],   // required — load skills from disk
  skills: "all",                         // or ["formatting-kes", "normalising-mpesa-phones"]
  allowedTools: ["Read", "Write", "Bash"],
};

Dispatch a skill directly by putting /<name> in the prompt string — this works even if the name is not in your skills allowlist. Confirm what loaded by reading the init system message: its skills array lists user-invocable skills, and slash_commands lists every dispatchable command (built-ins, bundled skills, your skills, .claude/commands/ files). One SDK-only caveat: for project/personal skills the allowed-tools frontmatter field is ignored — grant those tools through the SDK's allowedTools / allowed_tools option instead. In non-interactive (-p / SDK) runs a context: fork skill always blocks for its result rather than backgrounding.


Authoring checklist


codeAmani notes

Official docs:

Clerk Integration Guide

Technology: clerk · Category: auth · Last reviewed: 2026-06-18

Source: https://tech-stack.codeamanilabs.org/guide/clerk

Insight:

Clerk is the managed auth layer — drop-in Next.js components handle sign-in, sessions, and orgs so you don't roll your own. Current major is Clerk Core 3 (@clerk/nextjs v7): <ClerkProvider> now mounts inside <body> and the old <SignedIn>/<SignedOut>/<Protect> components are gone (use <Show>). Verify Clerk webhooks with Svix before trusting them, and consider an SMS-OTP fallback (Africa's Talking) for users without reliable email. Self-hosted alternative when per-MRU pricing or a JOIN-able user table matters: better-auth/CLAUDE_CODE_INTEGRATION.md.

 ██████╗██╗     ███████╗██████╗ ██╗  ██╗
██╔════╝██║     ██╔════╝██╔══██╗██║ ██╔╝
██║     ██║     █████╗  ██████╔╝█████╔╝
██║     ██║     ██╔══╝  ██╔══██╗██╔═██╗
╚██████╗███████╗███████╗██║  ██║██║  ██╗
 ╚═════╝╚══════╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝

Clerk Integration Guide

Focus: Integrating Clerk authentication into projects from Claude Code, using the official Clerk MCP server for SDK context, and automating user management workflows.

Overview

Clerk is a complete authentication and user management platform with pre-built UI components, JWT session management, webhooks, OAuth, and MFA. Its official MCP server provides Claude Code with up-to-date SDK snippets, implementation patterns, and integration guidance — ensuring Claude generates correct Clerk code rather than outdated patterns. Clerk also supports acting as an OAuth provider for MCP servers, enabling users to securely authorize AI agents to access your app's data.

Official Documentation

Resource URL
Clerk Docs https://clerk.com/docs
Clerk MCP Server https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server
Using Clerk with AI https://clerk.com/docs/guides/ai/overview
Next.js Quickstart https://clerk.com/docs/quickstarts/nextjs
Webhooks https://clerk.com/docs/integrations/webhooks
REST API https://clerk.com/docs/reference/backend-api

MCP Server Setup

Official Clerk MCP Server

Clerk provides a remote MCP server that gives Claude Code access to current SDK documentation, code snippets, and implementation patterns.

# Add Clerk MCP server to Claude Code
claude mcp add clerk -- npx -y mcp-remote https://mcp.clerk.com/mcp

.mcp.json Configuration

{
  "mcpServers": {
    "clerk": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.clerk.com/mcp"]
    }
  }
}

What the Clerk MCP Server Provides

Capability Description
SDK snippets Up-to-date @clerk/nextjs, @clerk/express, @clerk/backend examples
Component usage <SignIn>, <UserButton>, <ClerkProvider> patterns
Auth helpers auth(), currentUser(), getAuth() usage
Webhook setup Svix-verified webhook handler patterns
OAuth flows Social provider configuration examples
RBAC patterns Role and permission implementation guides

SDK Integration

Next.js (App Router)

Here is the core request flow that ties these pieces together — once you see it, the wiring below clicks into place.

flowchart TD
  A["User hits a route"] --> B["clerkMiddleware runs"]
  B --> Q1{"Public route?"}
  Q1 -->|"yes"| C["Allow through"]
  Q1 -->|"no"| D["auth.protect"]
  D --> Q2{"Valid session?"}
  Q2 -->|"yes"| E["Render protected page"]
  Q2 -->|"no"| F["Redirect to sign-in"]
  E --> G["auth and currentUser read userId"]

Requirements (Core 3): Node.js ≥ 20.9.0 and Next.js ≥ 15.2.3 (Next.js 13/14 are no longer supported).

# Recommended: the CLI scaffolds middleware, layout, and .env keys for you
npx -y clerk@latest init
# …or install manually
npm install @clerk/nextjs
# Verify the wiring afterwards
npx -y clerk@latest doctor

app/layout.tsx:

import { ClerkProvider } from "@clerk/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Core 3: ClerkProvider mounts INSIDE <body>, not wrapping <html>. */}
        <ClerkProvider>{children}</ClerkProvider>
      </body>
    </html>
  );
}

middleware.ts:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)", "/api/webhooks(.*)"]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
    "/__clerk/(.*)", // required for Clerk's own frontend API routes
  ],
};

app/dashboard/page.tsx (Protected route):

import { auth, currentUser } from "@clerk/nextjs/server";

export default async function DashboardPage() {
  const { userId } = await auth();
  const user = await currentUser();

  return (
    <div>
      <h1>Welcome, {user?.firstName}!</h1>
      <p>User ID: {userId}</p>
    </div>
  );
}

Client UI components

Clerk ships prebuilt components so you never hand-roll auth UI. <ClerkProvider> wraps the app and supplies auth context; <Show when="signed-in"> / <Show when="signed-out"> conditionally render based on session state; <UserButton> is the account menu/avatar; <SignInButton> / <SignUpButton> open the flows; and the <SignIn> / <SignUp> widgets mount on dedicated catch-all routes. In Clerk Core 3 (@clerk/nextjs v7) the old <SignedIn> / <SignedOut> / <Protect> control components have been removed entirely — rendering them now throws. Consolidate onto <Show>: map <SignedIn> → <Show when="signed-in">, <SignedOut> → <Show when="signed-out">, and <Protect role="…"> → <Show when={{ role: "…" }}> (import Show from the same package). <Show> also takes a fallback prop for the else branch.

app/layout.tsx (provider inside <body> + conditional header):

import {
  ClerkProvider,
  Show,
  SignInButton,
  SignUpButton,
  UserButton,
} from "@clerk/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>
          <header style={{ display: "flex", justifyContent: "flex-end", gap: 12, padding: 16 }}>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </ClerkProvider>
      </body>
    </html>
  );
}

app/sign-in/[[...sign-in]]/page.tsx (catch-all sign-in route):

import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
  return <SignIn />;
}

app/sign-up/[[...sign-up]]/page.tsx (catch-all sign-up route):

import { SignUp } from "@clerk/nextjs";

export default function SignUpPage() {
  return <SignUp />;
}

The component visibility maps to the middleware decision:

flowchart TD
  A["Page renders inside ClerkProvider"] --> B{"Session present?"}
  B -->|"yes"| C["Show when signed-in<br/>renders UserButton"]
  B -->|"no"| D["Show when signed-out<br/>renders SignInButton · SignUpButton"]
  D --> E["User clicks SignInButton"]
  E --> F["Catch-all route mounts SignIn widget"]

Gotcha: The [[...sign-in]] double-bracket optional catch-all is required — the widget handles sub-paths like /sign-in/factor-one and /sign-in/sso-callback internally. A plain page.tsx (no catch-all) breaks multi-factor and OAuth callback steps. Also make sure these routes stay public in middleware.ts (the createRouteMatcher example above already lists /sign-in(.*) and /sign-up(.*)).

API Route Protection

app/api/protected/route.ts:

import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

export async function GET() {
  const { userId, orgId } = await auth();

  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  return NextResponse.json({ userId, orgId, message: "Protected data" });
}

Node.js / Express Backend

npm install @clerk/express
import express from "express";
import { clerkMiddleware, getAuth } from "@clerk/express";

const app = express();
app.use(clerkMiddleware());

// Recommended: clerkMiddleware() + getAuth(req). `requireAuth()` still exists
// but is deprecated — check `isAuthenticated` yourself instead.
app.get("/api/profile", (req, res) => {
  const { isAuthenticated, userId } = getAuth(req);
  if (!isAuthenticated) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  res.json({ userId });
});

Backend SDK (Server-to-Server)

npm install @clerk/backend
import { createClerkClient } from "@clerk/backend";

const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY });

// List users
const { data: users } = await clerkClient.users.getUserList({ limit: 10 });

// Get a specific user
const user = await clerkClient.users.getUser(userId);

// Update user metadata
await clerkClient.users.updateUserMetadata(userId, {
  publicMetadata: { plan: "pro" },
  privateMetadata: { stripeCustomerId: "cus_..." },
});

// Delete a user
await clerkClient.users.deleteUser(userId);

Webhook Integration

This is the trust boundary that keeps your data safe — verify first, then act. The sequence below mirrors the handler code that follows.

sequenceDiagram
  participant C as "Clerk"
  participant R as "Webhook route"
  participant S as "Svix verify"
  participant DB as "Database"
  C->>R: "POST event with svix headers"
  R->>S: "Verify body and signature"
  alt valid signature
    S-->>R: "Verified event"
    R->>DB: "Apply user.created or user.deleted"
    R-->>C: "200 OK"
  else invalid
    S-->>R: "Throws"
    R-->>C: "400 Invalid signature"
  end

Setup Clerk Webhooks

Simpler path: verifyWebhook(req) from @clerk/nextjs/webhooks wraps Svix internally and reads CLERK_WEBHOOK_SIGNING_SECRET for you — no manual svix install or header plumbing. See PATTERNS.md and examples/webhook.ts. The raw-Svix flow below shows the mechanism underneath and stays useful in non-Next.js runtimes.

npm install svix  # only needed for the raw-Svix flow below

app/api/webhooks/clerk/route.ts:

import { Webhook } from "svix";
import { headers } from "next/headers";
import type { WebhookEvent } from "@clerk/nextjs/server";

export async function POST(req: Request) {
  const body = await req.text();
  const headerPayload = await headers();

  const wh = new Webhook(process.env.CLERK_WEBHOOK_SIGNING_SECRET!);
  let event: WebhookEvent;

  try {
    event = wh.verify(body, {
      "svix-id": headerPayload.get("svix-id")!,
      "svix-timestamp": headerPayload.get("svix-timestamp")!,
      "svix-signature": headerPayload.get("svix-signature")!,
    }) as WebhookEvent;
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "user.created":
      await createUserInDatabase(event.data.id, event.data.email_addresses[0].email_address);
      break;
    case "user.deleted":
      await deleteUserFromDatabase(event.data.id!);
      break;
  }

  return new Response(null, { status: 200 });
}

Environment Variables

# Public (safe to expose in frontend)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding

# Secret (server-side only — NEVER expose in frontend)
CLERK_SECRET_KEY=sk_live_...
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...

Automation Workflows

Claude Code Slash Command: Scaffold Auth

.claude/commands/clerk-auth.md:

Scaffold Clerk authentication for a Next.js App Router project.

Use the Clerk MCP server to get the latest implementation patterns, then:
1. Install `@clerk/nextjs` if not already in package.json
2. Create/update `middleware.ts` with `clerkMiddleware` and public routes
3. Wrap `app/layout.tsx` with `<ClerkProvider>`
4. Create `app/(auth)/sign-in/[[...sign-in]]/page.tsx` with `<SignIn>`
5. Create `app/(auth)/sign-up/[[...sign-up]]/page.tsx` with `<SignUp>`
6. Create `app/api/webhooks/clerk/route.ts` with user.created/deleted handlers
7. Add all required env vars to `.env.local`
8. Report what was created and any manual steps needed (webhook secret setup)

Usage: /project:clerk-auth

GitHub Actions: User Sync CI

# .github/workflows/clerk-sync.yml
name: Verify Clerk Config
on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - name: Check middleware exists
        run: test -f middleware.ts || (echo "Missing middleware.ts!" && exit 1)
      - name: Check env vars documented
        run: grep -q "CLERK_SECRET_KEY" .env.example || echo "Warning: CLERK_SECRET_KEY missing from .env.example"

Common Use Cases

Use Case Approach
Add auth to Next.js /project:clerk-auth slash command
Protect API routes await auth() (Next.js) / getAuth(req) + isAuthenticated (Express)
User metadata clerkClient.users.updateUserMetadata()
Sync users to DB Clerk webhook → user.created event
RBAC / permissions Clerk Organizations + auth().orgRole
MFA enforcement Clerk dashboard → Security settings

Troubleshooting

Issue Fix
401 on API route Ensure clerkMiddleware() is applied before route handler
Webhook signature fails Check CLERK_WEBHOOK_SIGNING_SECRET matches the Svix signing secret in the dashboard (env var renamed from CLERK_WEBHOOK_SECRET)
User not found after creation Webhook may have a delay; use clerkClient.users.getUser() to verify
Missing publishable key Check NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY in .env.local
Session not persisting Ensure <ClerkProvider> wraps the entire app layout

Official docs:

Cloudflare Integration Guide

Technology: cloudflare · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/cloudflare

Insight:

Cloudflare is the edge platform — Workers, D1, KV, and R2 (which serves this dashboard's thumbnails). R2 has zero egress fees, making it the cheap choice for serving images/assets to a bandwidth-constrained African audience. Keep API tokens scoped and server-side.

 ██████╗██╗      ██████╗ ██╗   ██╗██████╗ ███████╗██╗      █████╗ ██████╗ ███████╗
██╔════╝██║     ██╔═══██╗██║   ██║██╔══██╗██╔════╝██║     ██╔══██╗██╔══██╗██╔════╝
██║     ██║     ██║   ██║██║   ██║██║  ██║█████╗  ██║     ███████║██████╔╝█████╗
██║     ██║     ██║   ██║██║   ██║██║  ██║██╔══╝  ██║     ██╔══██║██╔══██╗██╔══╝
╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║     ███████╗██║  ██║██║  ██║███████╗
 ╚═════╝╚══════╝ ╚═════╝  ╚═════╝ ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝

Cloudflare Integration Guide

Focus: Building, deploying, and managing Cloudflare Workers, D1, KV, R2, and the full Cloudflare platform from Claude Code using the official MCP server and Wrangler CLI.

Overview

Cloudflare's developer platform offers Workers (serverless), D1 (SQLite at the edge), KV (key-value), R2 (object storage), Durable Objects, Queues, Hyperdrive, Pages, and 2,500+ API endpoints. Claude Code integrates through Cloudflare's remote MCP servers (*.mcp.cloudflare.com, installed via the cloudflare/skills plugin) and the wrangler CLI. Work from the root of your Workers project — Claude Code reads wrangler.jsonc to understand your bindings automatically.

Here is the big picture — a single request hits the edge, runs your Worker, and reaches whichever bindings it needs:

flowchart LR
  U["User request"] --> E["Cloudflare edge"]
  E --> W["Worker<br/>fetch handler"]
  W --> D1["D1<br/>SQLite at edge"]
  W --> KV["KV<br/>key-value cache"]
  W --> R2["R2<br/>object storage"]
  W --> RESP["Response to user"]

Official Documentation

Resource URL
Cloudflare Developers https://developers.cloudflare.com
Claude Code + Cloudflare https://developers.cloudflare.com/agent-setup/claude-code/
Cloudflare's own MCP servers https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/
Wrangler CLI https://developers.cloudflare.com/workers/wrangler/
Workers Docs https://developers.cloudflare.com/workers/
Workers Best Practices https://developers.cloudflare.com/workers/best-practices/workers-best-practices/
Workers limits https://developers.cloudflare.com/workers/platform/limits/
D1 Docs https://developers.cloudflare.com/d1/
R2 Docs https://developers.cloudflare.com/r2/

MCP Server Setup

This changed in 2026. Cloudflare no longer ships a single local npx server. The old @cloudflare/mcp-server-cloudflare package is legacy; the Cloudflare API server now lives at github.com/cloudflare/mcp. Today Cloudflare runs a catalog of managed remote MCP servers you connect to over OAuth.

Remote MCP servers

Every server is a hosted HTTPS endpoint under *.mcp.cloudflare.com. Use the Streamable HTTP endpoint at /mcp for new connections (the older /sse URL stays only as an alias — the deprecated HTTP+SSE transport is gone). Authorize with OAuth on first connect.

Server Streamable HTTP endpoint What it does
Cloudflare API ("Code Mode") https://mcp.cloudflare.com/mcp search() + execute() over 2,500+ API endpoints
Documentation https://docs.mcp.cloudflare.com/mcp Search the Cloudflare docs
Workers Bindings https://bindings.mcp.cloudflare.com/mcp Create/list Workers, KV, R2, D1, Hyperdrive
Workers Builds https://builds.mcp.cloudflare.com/mcp Inspect Workers Builds CI runs + logs
Observability https://observability.mcp.cloudflare.com/mcp Query Workers logs, traces, metrics
Radar https://radar.mcp.cloudflare.com/mcp Internet traffic + URL analysis
AI Gateway https://ai-gateway.mcp.cloudflare.com/mcp AI Gateway logs + config
Logpush https://logs.mcp.cloudflare.com/mcp Logpush job health
GraphQL https://graphql.mcp.cloudflare.com/mcp Query the Cloudflare GraphQL analytics API

(Full catalog — Container, Browser Run, AI Search/AutoRAG, Audit Logs, DNS Analytics, DEX, CASB — at the "Cloudflare's own MCP servers" doc above.)

Connect from Claude Code

The recommended path is the Cloudflare Skills plugin, which bundles the MCP servers with contextual skills and slash commands. Run inside Claude Code:

/plugin marketplace add cloudflare/skills
/plugin install cloudflare@cloudflare

Then verify the servers registered:

claude mcp list

To add a single remote server directly instead (Streamable HTTP transport):

claude mcp add --transport http cloudflare-bindings https://bindings.mcp.cloudflare.com/mcp

First use opens an OAuth browser flow. In CI (no browser), skip OAuth by passing a scoped Cloudflare API token as a bearer token — keep it in the environment manager, never in the repo (see ENV_MASTER.md).

Representative tools (Workers Bindings server)

The domain servers expose named tools; the Cloudflare API server exposes just search/execute (Code Mode). Common Workers Bindings tools:

Tool Description
workers_list List all Workers scripts
workers_get_worker_code Fetch Worker source
d1_databases_list / d1_database_query List D1 databases / run SQL
kv_namespaces_list List KV namespaces
r2_buckets_list / r2_bucket_create List / create R2 buckets
hyperdrive_configs_list List Hyperdrive configs

The Cloudflare MCP servers manage Workers/KV/R2/D1/Hyperdrive but cannot edit DNS and cannot upload R2 objects — use a scoped DNS token for DNS and wrangler r2 object put / the S3 API for object uploads (see the credentials table below).


CLI Integration (Wrangler)

Installation

npm install -g wrangler   # Wrangler 4.x is current (v4.125+); needs Node.js 20+

Authentication

# Interactive OAuth login
wrangler login

# Use API token (for CI)
export CLOUDFLARE_API_TOKEN=...

Key Commands

# Create a new Worker project
npm create cloudflare@latest my-worker -- --type worker

# Local development (with hot reload)
wrangler dev

# Deploy to Cloudflare
wrangler deploy

# View production logs (live tail)
wrangler tail my-worker

# D1 database commands
wrangler d1 create my-database
wrangler d1 execute my-database --command "CREATE TABLE users (id INTEGER PRIMARY KEY)"
wrangler d1 execute my-database --file schema.sql
wrangler d1 migrations apply my-database --local
wrangler d1 migrations apply my-database --remote

# KV namespace commands (v3.60+ uses a SPACE, not a colon; kv:namespace is deprecated)
wrangler kv namespace create MY_NAMESPACE
wrangler kv key put --binding=MY_NAMESPACE "key" "value"          # add --remote to write to production
wrangler kv key get --binding=MY_NAMESPACE "key"

# R2 bucket commands
wrangler r2 bucket create my-bucket
wrangler r2 object put my-bucket/path/to/file.json --file ./data.json

# Pages deployment
wrangler pages deploy dist/ --project-name my-site

# Secret management
wrangler secret put MY_SECRET
wrangler secret list

Worker Example

src/index.ts:

export interface Env {
  DB: D1Database;
  KV: KVNamespace;
  MY_SECRET: string;
}

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);

    if (url.pathname === "/users") {
      const { results } = await env.DB.prepare(
        "SELECT * FROM users ORDER BY created_at DESC LIMIT 10"
      ).all();
      return Response.json(results);
    }

    if (url.pathname === "/kv") {
      const value = await env.KV.get("my-key");
      return new Response(value ?? "not found");
    }

    return new Response("Not Found", { status: 404 });
  },
};

wrangler.jsonc:

{
  "name": "my-worker",
  "main": "src/index.ts",
  // Set this to today's date when you start a project, then bump deliberately.
  "compatibility_date": "2026-08-23",
  "d1_databases": [
    { "binding": "DB", "database_name": "my-database", "database_id": "..." }
  ],
  "kv_namespaces": [
    { "binding": "KV", "id": "..." }
  ]
}

Node.js compat is now on by default. For compatibility_date of 2026-08-04 or later, nodejs_compat (and nodejs_compat_v2) are enabled automatically — node:crypto, node:buffer, node:stream, etc. and npm packages that use them work with no flag. Only older compat dates still need "compatibility_flags": ["nodejs_compat"]. Prefer generating your Env type with wrangler types over hand-writing it, so config and types can't drift.


Bindings reference

Verified against developers.cloudflare.com/workers/wrangler/configuration. A binding is a direct, in-process handle to a Cloudflare resource on env — no network hop, no auth token. Best practice is to use bindings over REST APIs.

Resource wrangler.jsonc key Runtime type on env Create with
KV kv_namespaces: [{ binding, id }] KVNamespace wrangler kv namespace create
R2 r2_buckets: [{ binding, bucket_name }] R2Bucket wrangler r2 bucket create
D1 d1_databases: [{ binding, database_name, database_id }] D1Database wrangler d1 create
Durable Objects durable_objects.bindings: [{ name, class_name }] + migrations your DO class namespace declare class + migration
Queues (producer) queues.producers: [{ binding, queue }] Queue<T> wrangler queues create
Queues (consumer) queues.consumers: [{ queue, max_batch_size, dead_letter_queue }] queue() handler (bound on the consumer Worker)
Hyperdrive hyperdrive: [{ binding, id }] Hyperdrive wrangler hyperdrive create
// Durable Objects need a migration the first time a class is added.
{
  "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "jobs" }],
    "consumers": [{ "queue": "jobs", "max_batch_size": 10, "dead_letter_queue": "jobs-dlq" }]
  },
  "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<config-id>" }]
}

Pages & Functions

Verified against Cloudflare's official docs (developers.cloudflare.com/pages/functions). Pages serves your static build; Pages Functions add server-side code on the same deploy — file-based routing out of a functions/ directory, running on Workers.

Pages is two layers in one deploy: static assets plus an optional functions/ directory that Cloudflare compiles into a single Worker. Files map to URL paths automatically:

flowchart TD
  REQ["Incoming request"] --> RT["_routes.json<br/>include · exclude"]
  RT -->|"excluded path"| ASSET["Static asset<br/>from build dir"]
  RT -->|"included path"| FN["functions/ dir<br/>file-based routing"]
  FN --> H["onRequest handler<br/>EventContext"]
  H --> ENV["env bindings<br/>KV · D1 · R2"]
  H --> RESP["Response"]
  ASSET --> RESP

File-based routing

A file's path under functions/ becomes its route:

File Route
functions/index.ts /
functions/api/hello.ts /api/hello
functions/users/[user].ts /users/:user (single segment → params.user string)
functions/api/[[path]].ts /api/* (catch-all → params.path array)

More specific routes (fewer wildcards) win over catch-alls.

Pages Function example

A catch-all API handler at functions/api/[[path]].ts. Each onRequest (or method-specific onRequestGet / onRequestPost) receives an EventContext with request, env, params, waitUntil, next, and data. The PagesFunction<Env> generic types your bindings:

interface Env {
  KV: KVNamespace;
  DB: D1Database;
}

// Handles GET /api/anything/here
export const onRequestGet: PagesFunction<Env> = async (context) => {
  const { params, env } = context;
  // params.path is the segments after /api/ as a string[]
  const segments = params.path as string[];

  if (segments[0] === "ping") {
    return Response.json({ ok: true, ts: Date.now() });
  }

  const cached = await env.KV.get(segments.join("/"));
  return cached
    ? new Response(cached)
    : new Response("Not Found", { status: 404 });
};

// A bare onRequest runs for any verb without a more specific onRequestVerb export.
export const onRequest: PagesFunction<Env> = async ({ next }) => {
  return next(); // fall through to the static asset server
};

Deploy

# Build your site, then deploy the output directory (Functions in ./functions are bundled)
wrangler pages deploy dist/ --project-name my-site

# Local dev with Functions + bindings emulated
wrangler pages dev dist/

Bindings

Pages Functions read bindings off context.env, same as Workers. Configure them in wrangler.jsonc (or the Pages project's dashboard Settings → Bindings for production/preview). Keep compatibility_date current.

{
  "name": "my-site",
  "pages_build_output_dir": "dist",
  "compatibility_date": "2026-08-23",
  "kv_namespaces": [
    { "binding": "KV", "id": "..." }
  ],
  "d1_databases": [
    { "binding": "DB", "database_name": "my-database", "database_id": "..." }
  ]
}

_routes.json

Cloudflare auto-generates this, but you can ship your own at the build-output root to control which paths invoke Functions (vs. serving a static asset directly). exclude takes priority over include; wildcards match any number of segments:

{
  "version": 1,
  "include": ["/api/*"],
  "exclude": ["/api/static/*"]
}

Gotcha: if a path matches no include rule (or hits an exclude), the request is served as a static asset and your Function never runs — a silent 404/wrong-content instead of an error. When an API route mysteriously bypasses your handler, check _routes.json first. Run wrangler pages deploy to regenerate the auto version.


Environment Variables

# Required
CLOUDFLARE_API_TOKEN=...         # From dash.cloudflare.com → Profile → API Tokens
CLOUDFLARE_ACCOUNT_ID=...        # From dash.cloudflare.com (right sidebar)

# Wrangler picks these up automatically from environment
# Or use: wrangler secret put MY_SECRET for runtime secrets

Automation Workflows

Claude Code Hook: Auto-deploy on Save

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_FILE_PATH\" | grep -q 'src/'; then echo 'Worker source changed — run wrangler deploy to push'; fi"
          }
        ]
      }
    ]
  }
}

Slash Command: Deploy and Tail Logs

.claude/commands/cf-deploy.md:

Deploy the current Cloudflare Worker and confirm it's live.

1. Use Bash to run `wrangler deploy` and capture the deployed URL
2. Use Bash to run `wrangler tail --format pretty` for 10 seconds to check for errors
3. Report the deployed Worker URL and any runtime errors observed

Usage: /project:cf-deploy

GitHub Actions: CI Deploy to Cloudflare Workers

# .github/workflows/cloudflare.yml
name: Deploy Worker
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - name: Run D1 migrations
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
        run: npx wrangler d1 migrations apply my-database --remote
      - name: Deploy Worker
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}

R2: Create Buckets & API Tokens (step-by-step)

Verified against Cloudflare's official docs (developers.cloudflare.com/r2). Bucket names: lowercase letters, numbers, hyphens only.

You have three clean paths into R2 — pick the one that matches the job, and serving objects publicly is just as straightforward:

flowchart TD
  CC["Claude Code"] --> WR["Wrangler<br/>r2 object put"]
  CC --> S3["S3 API<br/>Access Key + Secret"]
  CC --> MCP["Cloudflare MCP<br/>create-list buckets"]
  WR --> B["R2 bucket"]
  S3 --> B
  MCP --> B
  B --> CD["Custom domain"]
  B --> RD["r2.dev URL"]
  CD --> IMG["world-readable img on frontend"]
  RD --> IMG

Create a bucket

Dashboard — open R2 → Overview

  1. Go to R2 object storage → Overview.
  2. Select Create bucket.
  3. Enter a name, pick a location + default storage class.
  4. Select Create bucket.

Wrangler (auth via wrangler login, no keys needed):

npx wrangler r2 bucket create my-bucket
npx wrangler r2 bucket list

REST API (needs an API token with R2 edit — see below):

curl https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/r2/buckets \
  -H "Authorization: Bearer $R2_ADMIN_TOKEN" -H "Content-Type: application/json" \
  --data '{"name":"my-bucket"}'

Get R2 API tokens (S3 Access Key ID + Secret)

Needed for S3 SDKs (boto3, AWS SDK, rclone) and for an app to read/write objects. Wrangler does not need these.

Dashboard — open R2 API tokens

  1. R2 → Overview → under Account details, select Manage next to API Tokens.
  2. Choose Create Account API token (tied to the account, survives user removal — best for automation) or Create User API token (tied to your user).
  3. Under Permissions pick one: Object Read & Write (typical), Object Read, Admin Read & Write, or Admin Read.
  4. Select Apply to specific buckets only and choose your bucket (least privilege).
  5. Create API Token, then copy the Access Key ID + Secret Access Key now — the secret is shown only once.
  6. Your S3 endpoint is https://<ACCOUNT_ID>.r2.cloudflarestorage.com.

Deriving S3 creds from any Cloudflare API token: Access Key ID = the token's id; Secret Access Key = the SHA‑256 hash of the token value.

Serve objects publicly (for <img> on the frontend)

Credentials & Permissions for Claude Code automation

What Claude Code needs to automate Cloudflare, and the gotchas that block it:

Credential Create at Scope / permission Lets Claude Code automate
Account ID R2 Overview / dash URL identifier (not secret) target API + S3 endpoint
Zone ID domain → Overview (API section) identifier (not secret) DNS API calls for that zone
R2 S3 token (Access Key + Secret) R2 → Manage R2 API Tokens Object Read & Write, scoped to a bucket upload/serve objects (boto3, AWS SDK, rclone, app proxy)
R2 admin token Account API Tokens → Custom Workers R2 Storage: Edit create/list/delete buckets + settings via API
DNS token Account API Tokens → Custom Zone → DNS → Edit (+ Zone → Read) add/edit DNS records (subdomains, R2 custom domains)
Global API Key My Profile → API Tokens full account (legacy) everything via wrangler legacy auth
OAuth (wrangler login) local browser your user's permissions all local wrangler commands

Automation gotchas (learned the hard way):


Common Use Cases

Use Case Approach
Edge API with D1 Worker + wrangler d1 execute for schema
Global KV cache KVNamespace binding in Worker
Static site wrangler pages deploy dist/
File storage R2 bucket + Worker presigned URLs
Background jobs / fan-out Queues producer + separate consumer Worker
Query an existing Postgres/MySQL DB Hyperdrive binding (connection pooling + cache)
Stateful coordination / counters Durable Objects (SQLite storage)
Rate limiting Cloudflare Rate Limiting via the API MCP execute
DNS management scoped DNS token (not the MCP — it can't edit DNS)

Troubleshooting

Issue Fix
CLOUDFLARE_API_TOKEN missing Create token at dash.cloudflare.com with Workers:Edit permissions
wrangler dev port conflict Use wrangler dev --port 8788
D1 migration not applying Run wrangler d1 migrations list my-database --remote to check state
Worker over size limit Limit is 3 MB (Free) / 10 MB (Paid) after gzip — minify (on by default in v4), trim deps, or split into sub-workers
kv:namespace command errors The colon form is deprecated — use a space: wrangler kv namespace ..., wrangler kv key ...
Exceeded CPU time (Error 1102) Free CPU is 10 ms; on Paid raise limits.cpu_ms (default 30 s, max 5 min) or offload to Queues/Durable Objects
KV stale reads KV is eventually consistent; use D1 or Durable Objects for strong consistency

Official docs:

Cloudflare R2 (Private Media Storage) Integration Guide

Technology: cloudflare-r2 · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/cloudflare-r2

Insight:

R2 buckets are private by default — the whole job is to not break that. Never make the bucket public for sensitive media; instead serve it two safe ways: short-lived presigned URLs (S3 SigV4, minted server-side so your keys never reach the browser) or a Worker that authorizes every request (auth-key for writes, allow-list / session check for reads → 403 otherwise). Reserve r2.dev for throwaway assets — it has no WAF, cache, or access controls; use a custom domain for anything real.

 ██████╗██╗      ██████╗ ██╗   ██╗██████╗ ███████╗██╗      █████╗ ██████╗ ███████╗    ██████╗ ██████╗
██╔════╝██║     ██╔═══██╗██║   ██║██╔══██╗██╔════╝██║     ██╔══██╗██╔══██╗██╔════╝    ██╔══██╗╚════██╗
██║     ██║     ██║   ██║██║   ██║██║  ██║█████╗  ██║     ███████║██████╔╝█████╗      ██████╔╝ █████╔╝
██║     ██║     ██║   ██║██║   ██║██║  ██║██╔══╝  ██║     ██╔══██║██╔══██╗██╔══╝      ██╔══██╗██╔═══╝
╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║     ███████╗██║  ██║██║  ██║███████╗    ██║  ██║███████╗
 ╚═════╝╚══════╝ ╚═════╝  ╚═════╝ ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝    ╚═╝  ╚═╝╚══════╝

Cloudflare R2 (Private Media Storage) Integration Guide

Focus: Storing user-uploaded media (images, receipts, KYC docs, audio) in Cloudflare R2 so it stays private — private buckets, server-minted presigned URLs, Worker-gated access, and direct-to-R2 browser uploads that never expose your credentials.

Overview

Cloudflare R2 is S3-compatible object storage with zero egress fees — you pay to store and to operate, but not to serve bytes out. That makes it ideal for African-market media serving where bandwidth is the expensive part. R2 speaks the S3 API, so the AWS SDKs work unchanged against an R2 endpoint, and it also exposes a native Workers binding (env.MY_BUCKET.get/put/delete) for edge access.

The security headline: buckets are private by default. Nothing is reachable from the Internet until you explicitly attach a public custom domain or an r2.dev URL. For private media you keep it that way and hand out temporary, scoped access instead.

There are exactly two safe ways to let a user read or write a private object — pick per use case:

flowchart TD
  A["User needs a private object"] --> B{"Read or write?"}
  B -->|"one-off, time-boxed"| C["Presigned URL<br/>(S3 SigV4, expiresIn)"]
  B -->|"every request needs a policy"| D["Worker in front of bucket<br/>(authorize then env.BUCKET.get)"]
  C --> E["Client gets a capability URL,<br/>never your keys"]
  D --> F["Worker checks auth/session,<br/>returns 403 or streams object"]
  E --> G["Object expires from reach<br/>when the URL does"]
  F --> G

Official Documentation

Resource URL
R2 docs home https://developers.cloudflare.com/r2/
Presigned URLs (S3) https://developers.cloudflare.com/r2/api/s3/presigned-urls/
Workers API usage https://developers.cloudflare.com/r2/api/workers/workers-api-usage/
Public buckets (when not to) https://developers.cloudflare.com/r2/buckets/public-buckets/
API tokens / S3 credentials https://developers.cloudflare.com/r2/api/tokens/

The privacy model (read this first)

flowchart LR
  P["Private bucket<br/>(default)"] -->|"NEVER for sensitive media"| Pub["Public: custom domain or r2.dev"]
  P -->|"recommended"| Pre["Presigned URLs<br/>short TTL"]
  P -->|"recommended"| Wk["Worker gate<br/>auth per request"]
  Pub -->|"only safe with"| WAF["custom domain +<br/>WAF / Access rules"]

Five rules that keep media private:

  1. Leave the bucket private. Do not enable a public bucket for user data. Public = anyone with the URL, forever.
  2. Credentials are server-only. S3 access keys and the AUTH_KEY_SECRET live in server env / Wrangler secrets — never in client JS, never in NEXT_PUBLIC_*.
  3. Hand out short-lived capability URLs. Presigned URLs expire (expiresIn seconds; hard max 7 days / 604,800s). Mint them on demand, scope them to one object + one operation, keep TTL small (minutes, not days). A presigned URL is reusable until it expires — it is not single-use — so short TTLs are your safety margin.
  4. r2.dev is for throwaway assets only. It has no WAF, no cache, no access controls. Anything private or production-grade goes behind a custom domain (which unlocks WAF + Cloudflare Access) or a Worker.
  5. Scope your API tokens. Issue per-bucket, least-privilege tokens (read-only for a download service, read-write only where uploads happen). R2 encrypts objects at rest automatically.

Setup

S3 credentials (for presigned URLs / SDK access)

Create an R2 API token (R2 → Manage API Tokens) to get an Access Key ID + Secret. The S3 endpoint is https://<ACCOUNT_ID>.r2.cloudflarestorage.com.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/r2.ts — server-only client. Never import this into client components.
import { S3Client } from "@aws-sdk/client-s3";

export const r2 = new S3Client({
  region: "auto", // required by the SDK, ignored by R2
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export const R2_BUCKET = process.env.R2_BUCKET!;

Pattern A — Presigned download URL (time-boxed read)

Generate a short-lived GET URL on the server, hand it to the authenticated user. The bucket stays private; the URL stops working when it expires.

// app/api/media/[key]/route.ts
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { r2, R2_BUCKET } from "@/lib/r2";

export async function GET(req: NextRequest, { params }: { params: { key: string } }) {
  const { userId } = await auth();
  if (!userId) return new NextResponse("Unauthorized", { status: 401 });

  // Authorize: only let a user fetch their own object (key is namespaced by userId).
  if (!params.key.startsWith(`${userId}/`)) {
    return new NextResponse("Forbidden", { status: 403 });
  }

  const url = await getSignedUrl(
    r2,
    new GetObjectCommand({ Bucket: R2_BUCKET, Key: params.key }),
    { expiresIn: 300 }, // 5 minutes — keep it short
  );

  return NextResponse.redirect(url);
}

Pattern B — Presigned upload URL (direct browser → R2)

The browser uploads straight to R2 with a presigned PUT, so the file never transits your server. Pin the ContentType so the client can't upload something else under that key.

// app/api/uploads/route.ts — returns a short-lived PUT URL
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { r2, R2_BUCKET } from "@/lib/r2";

const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);

export async function POST(req: NextRequest) {
  const { userId } = await auth();
  if (!userId) return new NextResponse("Unauthorized", { status: 401 });

  const { filename, contentType } = await req.json();
  if (!ALLOWED.has(contentType)) {
    return new NextResponse("Unsupported media type", { status: 415 });
  }

  // Namespace the key by user so one user can't overwrite another's media.
  const key = `${userId}/${crypto.randomUUID()}-${filename}`;

  const uploadUrl = await getSignedUrl(
    r2,
    new PutObjectCommand({ Bucket: R2_BUCKET, Key: key, ContentType: contentType }),
    { expiresIn: 120 },
  );

  return NextResponse.json({ uploadUrl, key });
}
// client — the PUT must send the SAME Content-Type used to sign the URL
const { uploadUrl, key } = await fetch("/api/uploads", {
  method: "POST",
  body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then((r) => r.json());

await fetch(uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": file.type }, // must match, or signature mismatch
  body: file,
});

Gotcha: a presigned PUT URL is signed over the Content-Type. If the client's Content-Type header doesn't match what you passed to PutObjectCommand, R2 rejects it with a signature error. Send the exact same value.


Pattern C — Worker in front of the bucket (policy per request)

When every request needs a live authorization decision (not just "has a valid URL"), put a Worker in front using the native binding. This is the canonical R2 access-control pattern: a pre-shared key gates writes, an allow-list / session check gates reads, everything else is 403.

Wrangler's default config format is now wrangler.jsonc; the TOML equivalent below still works unchanged.

// wrangler.jsonc
{
  "name": "media-gateway",
  "main": "src/index.ts",
  "r2_buckets": [
    { "binding": "MEDIA", "bucket_name": "amani-media" } // -> env.MEDIA
  ]
}
# wrangler.toml (equivalent)
name = "media-gateway"
main = "src/index.ts"

[[r2_buckets]]
binding = "MEDIA"            # -> env.MEDIA
bucket_name = "amani-media"
// src/index.ts
const hasValidHeader = (request: Request, env: Env) =>
  request.headers.get("X-Custom-Auth-Key") === env.AUTH_KEY_SECRET;

function authorize(request: Request, env: Env, key: string): boolean {
  switch (request.method) {
    case "PUT":
    case "DELETE":
      return hasValidHeader(request, env); // writes need the shared secret
    case "GET":
      // e.g. verify a signed session cookie / JWT here instead of an allow-list
      return verifySession(request);
    default:
      return false;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const key = new URL(request.url).pathname.slice(1);
    if (!authorize(request, env, key)) {
      return new Response("Forbidden", { status: 403 });
    }

    if (request.method === "GET") {
      const object = await env.MEDIA.get(key);
      if (!object) return new Response("Not Found", { status: 404 });
      const headers = new Headers();
      object.writeHttpMetadata(headers);
      headers.set("etag", object.httpEtag);
      return new Response(object.body, { headers });
    }

    if (request.method === "PUT") {
      await env.MEDIA.put(key, request.body);
      return new Response("OK", { status: 201 });
    }

    return new Response("Method Not Allowed", { status: 405 });
  },
};
# the shared secret lives as a Wrangler secret, never in your Wrangler config
npx wrangler secret put AUTH_KEY_SECRET

Choosing a pattern

flowchart TD
  A["What are you serving?"] --> B{"Short-lived link is enough?"}
  B -->|"yes — download a doc, view a photo"| C["Presigned GET (Pattern A)"]
  B -->|"no — policy can change per request,<br/>or you want WAF / rate-limit / caching"| D["Worker gate (Pattern C)"]
  A --> E{"Letting users upload?"}
  E -->|"yes"| F["Presigned PUT (Pattern B)<br/>+ ContentType + size limits"]
Need Pattern Why
Time-limited download of a private file A — presigned GET No infra; URL expires on its own
Direct browser upload, bytes skip your server B — presigned PUT Offloads bandwidth; pin ContentType
Live per-request authz, WAF, caching, rate-limit C — Worker + custom domain Full control at the edge
Public, non-sensitive assets (logos, OG images) Public bucket on a custom domain Only when leakage is harmless

Environment Variables

# Server-only — never NEXT_PUBLIC_*
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=amani-media

# Worker (Pattern C) — set via `wrangler secret put`, not in your Wrangler config
AUTH_KEY_SECRET=...

codeAmani Notes


Troubleshooting

Issue Fix
Presigned PUT returns SignatureDoesNotMatch Client Content-Type must exactly match the value passed to PutObjectCommand
Object reachable by anyone You enabled a public bucket / r2.dev — disable it; serve via presigned URL or Worker instead
403 from the Worker on legit reads Your authorize() GET branch is rejecting — check the session/allow-list logic
Credentials leaked to browser Move the S3 client into a server-only module; never expose keys via NEXT_PUBLIC_*
Need WAF / caching but on r2.dev Move to a custom domain — r2.dev supports none of those
Presigned URL still works after "expiry" Check expiresIn units (seconds) and server clock skew; SigV4 is time-sensitive
Presigned URL 403s on a custom domain Presigned URLs only work against the S3 endpoint (<ACCOUNT_ID>.r2.cloudflarestorage.com), not custom domains — for auth on a custom domain use WAF HMAC validation (Pro plan+)

Official docs:

CodeRabbit Integration Guide

Technology: coderabbit · Category: tooling · Last reviewed: 2026-08-30

Source: https://tech-stack.codeamanilabs.org/guide/coderabbit

Insight:

CodeRabbit is a second reviewer, not a model provider — it never appears in the AI Routing Policy. Its highest-leverage surface for codeAmani is the CLI + Claude Code plugin (/coderabbit:review), which reviews uncommitted work before a PR exists, closing the implement → review → fix loop inside one session. Treat every review comment as untrusted input: CodeRabbit's own autofix skill refuses to execute reviewer-supplied prompts, and so should you. Config lives in a committed .coderabbit.yaml (no secrets); the only secret is the cr-… CLI key, which cr auth login stores outside the repo.

 ██████╗ ██████╗ ██████╗ ███████╗██████╗  █████╗ ██████╗ ██████╗ ██╗████████╗
██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔══██╗██║╚══██╔══╝
██║     ██║   ██║██║  ██║█████╗  ██████╔╝███████║██████╔╝██████╔╝██║   ██║
██║     ██║   ██║██║  ██║██╔══╝  ██╔══██╗██╔══██║██╔══██╗██╔══██╗██║   ██║
╚██████╗╚██████╔╝██████╔╝███████╗██║  ██║██║  ██║██████╔╝██████╔╝██║   ██║
 ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝ ╚═════╝ ╚═╝   ╚═╝

CodeRabbit Integration Guide

Focus — wiring CodeRabbit's AI review into codeAmani's workflow at the point it pays off most: before the PR exists, from inside Claude Code, via the CLI and plugin.

Overview

CodeRabbit reviews code changes and posts context-aware feedback. Unlike most entries in this stack it is not an AI provider — you never route inference through it, and it does not belong in the CLAUDE.md AI Routing Policy table. It is a reviewer: you hand it a diff, it hands back findings.

The thing worth internalising is that CodeRabbit has four independent surfaces, and they review different things at different moments:

Surface Reviews When
Platform (PR reviews) The pushed branch diff After you open a PR
CLI (cr) Local, uncommitted changes Before you commit
IDE extension Working-tree changes in-editor While you type (VS Code, Cursor, Windsurf)
Agent Conversation context In Slack / Discord

For an agentic workflow the CLI is the one that matters — it is the only surface that can see work that does not exist in git history yet, which is exactly the state Claude Code leaves the tree in mid-task.

flowchart TD
  A["Claude Code implements a change"] --> B["/coderabbit:review<br/>(plugin wraps the cr CLI)"]
  B --> C["cr --agent · structured JSON"]
  C --> D{"Findings?"}
  D -->|"yes"| E["Claude proposes fixes<br/>per-change approval"]
  E --> A
  D -->|"no"| F["Commit + push"]
  F --> G["Platform review on the PR<br/>@coderabbitai commands"]

Official Documentation

Resource URL
Docs home https://docs.coderabbit.ai/
CLI overview https://docs.coderabbit.ai/cli/overview
Claude Code integration https://docs.coderabbit.ai/cli/claude-code-integration
YAML configuration https://docs.coderabbit.ai/getting-started/yaml-configuration
Review commands https://docs.coderabbit.ai/guides/commands
Command reference https://docs.coderabbit.ai/reference/review-commands
Path instructions https://docs.coderabbit.ai/configuration/path-instructions
Tool integrations https://docs.coderabbit.ai/tools/

Git platforms: GitHub, GitLab, Azure DevOps, Bitbucket. Issue trackers: Jira, Linear.

No npm/PyPI package. The packages: frontmatter is deliberately empty — CodeRabbit ships a native CLI binary via a shell installer or Homebrew, not a JS/Python library. (The coderabbit name on npm is an unrelated security placeholder; do not install it.)


Claude Code Integration (the path codeAmani uses)

CodeRabbit ships a first-party Claude Code plugin. It is already enabled in this workspace (coderabbit@claude-plugins-official, v1.1.1).

# Vendor marketplace (as documented upstream)
/plugin marketplace add coderabbitai/claude-plugin
/plugin install coderabbit

# Or from the official marketplace — this is what this workspace uses
claude plugin install coderabbit

What the plugin actually adds:

Component Name Purpose
Command /coderabbit:review Run a review on the current changes
Skill code-review Default review skill; also fires autonomously when a review is warranted
Skill autofix Apply CodeRabbit PR-thread feedback with per-change approval
Agent code-reviewer Delegated review subagent
/coderabbit:review                      # tracked changes
/coderabbit:review committed            # committed modifications only
/coderabbit:review uncommitted          # staged + local edits
/coderabbit:review --include-untracked  # include new files
/coderabbit:review --base main          # diff against a specific branch

The workflow the plugin is designed around is a single instruction that closes the loop:

"Implement phase 7.3 of the plan, then review it with CodeRabbit and fix what it finds."

Claude implements → runs the review → reads findings → proposes fixes → repeats. Prefer the plugin over raw cr calls: it already knows how to parse the structured output.


CLI Setup

# macOS / Linux
curl -fsSL https://cli.coderabbit.ai/install.sh | sh

# Homebrew
brew install coderabbit

# Windows (PowerShell)
irm https://cli.coderabbit.ai/install.ps1 | iex

Authenticate once — credentials are stored outside the repo:

cr auth login                        # US region
cr auth login --region eu            # EU region (data residency)
cr auth login --api-key "cr-..."     # non-interactive / CI

Core commands:

cr                      # review local changes (alias for `coderabbit review`)
cr review --light       # faster, shallower pass
cr --agent              # structured JSON — this is what the plugin consumes
cr doctor               # diagnose setup problems
cr stats                # review statistics

cr --agent is the integration seam. If you are scripting CodeRabbit into a hook or CI step, parse that JSON — do not scrape the human-readable output, which is formatted for a terminal and will change.


.coderabbit.yaml

Configuration is a committed file at the repo root. It holds no secrets, so it is safe to check in and review like any other config.

language: "en-US"
reviews:
  profile: "chill"              # "chill" | "assertive"
  request_changes_workflow: false
  high_level_summary: true
  poem: true
  review_status: true
  review_details: false
  auto_review:
    enabled: true
    drafts: false
chat:
  auto_reply: true

Path instructions — the highest-value knob

Scope review focus per directory with glob patterns. This is where codeAmani conventions get enforced automatically:

reviews:
  path_instructions:
    - path: "app/api/**"
      instructions: |
        - Verify webhook signatures BEFORE processing any payload.
        - Flag any secret read outside a server-only module.
        - Flag `execSync` with an interpolated string; require execFileSync(cmd, [args]).
    - path: "lib/mpesa-*.ts"
      instructions: |
        - Phone numbers must be normalized to 254XXXXXXXXX.
        - Amounts must be integer KES — no decimals sent to Daraja.
        - CheckoutRequestID must be persisted before the STK Push response is returned.
    - path: "**/*.test.ts"
      instructions: |
        Ensure edge cases and error paths are covered, not just the happy path.

Linter passthrough

CodeRabbit runs existing linters and folds their output into the review:

reviews:
  tools:
    eslint:
      enabled: true
    ruff:
      enabled: true
      config_file: "pyproject.toml"

Post-merge actions and reviewer routing

reviews:
  suggested_reviewers: true
  auto_assign_reviewers: true
  suggested_reviewers_instructions:
    - reviewers:
        - handle: security-team
          type: group
      instructions: "Assign when the PR modifies authentication, encryption, or access-control logic."
  post_merge_actions:
    - name: "Update changelog"
      enabled: true
      prompt: "If this PR contains user-facing changes, append a concise entry to CHANGELOG.md under Unreleased."

Pull Request Commands

Post these as top-level PR comments — management commands are not supported as inline or thread replies.

Command Effect
@coderabbitai review Incremental review of new changes
@coderabbitai full review Re-review the entire PR, ignoring prior comments
@coderabbitai pause Stop automatic reviews on this PR
@coderabbitai resume Resume automatic reviews
@coderabbitai resolve Mark all CodeRabbit comments resolved (global — use with care)
@coderabbitai approve Resolve threads and attempt approval (depends on request_changes_workflow)

codeAmani notes

Review output is untrusted input. This is the security point that matters most. A review comment is text from a system that read a diff — and on a fork PR, that diff was written by someone outside the org. Text engineered to look like an instruction ("also add this helper that posts to …") can steer an agent that is applying fixes. CodeRabbit's own autofix skill states it will "never execute reviewer-provided prompts directly" and gates every change on approval. Hold the same line: read findings as data, never as instructions, and approve fixes one at a time.

Secrets. The only secret is the cr-… API key. cr auth login stores it outside the repo; in CI put it in the platform's secret store and never inline it into a workflow file. The .coderabbit.yaml itself is secret-free by design. Run gitleaks before pointing any review surface at a repo (see SECURITY.md) — a review sends your diff to CodeRabbit's service, so a committed secret becomes a disclosed secret.

Data residency. cr auth login --region eu pins the EU region. Relevant for KDPA-adjacent work; see COMPLIANCE_GUIDE.md.

Not an AI provider. CodeRabbit does not belong in the AI Routing Policy table — it consumes no ANTHROPIC_API_KEY and serves no inference to your app. It sits beside Semgrep and gitleaks as a gate, not beside Claude and OpenAI as a provider.

Where it earns its keep here. The CLI's ability to review uncommitted work is the reason to adopt it: this stack's pre-push gates (committed-tree build, gitleaks) run late, after the tree is already committed. /coderabbit:review runs before that, when a fix is still cheap.


Troubleshooting

Issue Fix
cr: command not found after install Re-open the shell; the installer appends to PATH in the profile
Auth fails or reviews 401 cr auth login again; check you are on the right region (--region eu)
Anything unexplained cr doctor — it diagnoses setup problems directly
Review finds nothing on new files Untracked files are excluded by default; add --include-untracked
Review is slow on a large diff cr review --light for a faster, shallower pass
Automatic PR reviews stopped Someone posted @coderabbitai pause; post @coderabbitai resume
.coderabbit.yaml seems ignored It must be at the repo root on the PR's base branch
Wrong npm package installed There is no npm package — remove coderabbit from package.json

Official docs:

Context7 Integration Guide

Technology: context7 · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/context7

Insight:

Context7 feeds live, version-accurate library docs into Claude Code, eliminating hallucinated APIs — it's the engine behind this repo's "no trained-data guessing" rule. Always resolve-library-id then query-docs before writing integration code against an unfamiliar or fast-moving SDK. The v4 tools take a plain-English query (the old topic/tokens knobs are gone) — a tight, single-concept query is now the only lever you have on what comes back.

 ██████╗ ██████╗ ███╗   ██╗████████╗███████╗██╗  ██╗████████╗███████╗
██╔════╝██╔═══██╗████╗  ██║╚══██╔══╝██╔════╝╚██╗██╔╝╚══██╔══╝╚════██║
██║     ██║   ██║██╔██╗ ██║   ██║   █████╗   ╚███╔╝    ██║       ██╔╝
██║     ██║   ██║██║╚██╗██║   ██║   ██╔══╝   ██╔██╗    ██║      ██╔╝
╚██████╗╚██████╔╝██║ ╚████║   ██║   ███████╗██╔╝ ██╗   ██║      ██║
 ╚═════╝ ╚═════╝ ╚═╝  ╚═══╝   ╚═╝   ╚══════╝╚═╝  ╚═╝   ╚═╝      ╚═╝

Context7 Integration Guide

Focus: Feeding live, version-accurate library documentation into Claude Code sessions using the Context7 MCP server — eliminating hallucinated API calls.

Overview

Context7 is an MCP server built specifically for AI coding assistants. It solves one of the biggest LLM pain points: outdated training data causing hallucinated or deprecated API usage. When Claude Code is connected to Context7, it can resolve any library by name and pull current, version-specific documentation directly into its context — ensuring generated code uses the right API signatures every time.

Core value proposition: Instead of Claude guessing at a library's API, Context7 fetches the actual, current docs and injects them into the conversation.

Here's the core idea at a glance — Context7 turns guesswork into grounded code:

flowchart LR
    A["Library name + query"] --> B["resolve-library-id"]
    B --> C["Context7 library ID"]
    C --> D["query-docs"]
    D --> E["Live version-accurate docs"]
    E --> F["Claude Code writes grounded code"]

Renamed in v4. The docs tool is now query-docs (formerly get-library-docs), and both tools take a plain-English query instead of the old topic/tokens parameters. If you have older CLAUDE.md rules or slash commands referencing get-library-docs, update them — see the tool table below.

Official Documentation

Resource URL
Context7 Website https://context7.com
npm Package (MCP server) https://www.npmjs.com/package/@upstash/context7-mcp
npm Package (ctx7 CLI) https://www.npmjs.com/package/ctx7
GitHub https://github.com/upstash/context7
Manual install / all clients https://context7.com/docs/resources/all-clients
API reference https://context7.com/docs/api-guide
CLI reference https://context7.com/docs/clients/cli

MCP Server Setup

Context7 v4 ships two ways to consume it, both installable with one command:

API key recommended (not required). The free tier still works with no key, but the docs now recommend a free key from context7.com/dashboard for higher rate limits. Keep it out of the repo — see Environment Variables.

The ctx7 CLI (Node.js 18+) authenticates via OAuth, generates an API key, and wires up either mode. Target Claude Code with --claude:

npx ctx7 setup --claude

To undo it later: npx ctx7 remove (and, if you installed the CLI globally with npm install -g ctx7, also npm uninstall -g ctx7).

Remote (hosted) MCP — manual .mcp.json

The hosted server lives at https://mcp.context7.com/mcp; pass the key as a Bearer header.

{
  "mcpServers": {
    "context7": {
      "type": "http",
      "url": "https://mcp.context7.com/mcp",
      "headers": {
        "Authorization": "Bearer ${CONTEXT7_API_KEY}"
      }
    }
  }
}

Local stdio MCP — manual .mcp.json

The @upstash/context7-mcp package (v4.x) still runs as a local stdio process — handy when you'd rather not depend on the hosted endpoint:

# Add Context7 to Claude Code as a local stdio server
claude mcp add context7 -- npx -y @upstash/context7-mcp
{
  "mcpServers": {
    "context7": {
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp"]
    }
  }
}

With an API key for higher rate limits (pass it as a CLI flag on the server):

{
  "mcpServers": {
    "context7": {
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp", "--api-key", "${CONTEXT7_API_KEY}"]
    }
  }
}

Full per-client setup for 30+ clients: https://context7.com/docs/resources/all-clients

Available MCP Tools

Tool Purpose Required params
resolve-library-id Map a library name to a Context7 library ID libraryName, query
query-docs Fetch current docs for a resolved library ID libraryId, query

query-docs was get-library-docs before v4. The old context7CompatibleLibraryID, topic, and tokens parameters are gone — see How Context7 Works for the current parameter shapes. Neither tool should be called more than 3 times per question.


How Context7 Works in Practice

The workflow is always two steps:

You've got this — the sequence below shows exactly how the two tools cooperate per request:

sequenceDiagram
    participant U as "You"
    participant C as "Claude Code"
    participant M as "Context7 MCP"
    U->>C: "Use the latest library API"
    C->>M: "resolve-library-id with libraryName and query"
    M-->>C: "Context7 library ID"
    C->>M: "query-docs with libraryId and query"
    M-->>C: "Current docs"
    C-->>U: "Code matching documented API"

Step 1: Resolve the Library ID

Tool: resolve-library-id
Input: { "libraryName": "Next.js", "query": "app router caching" }
Output (one of several candidates):
  {
    "id": "/vercel/next.js",
    "name": "Next.js",
    "description": "Next.js enables you to create full-stack web applications...",
    "codeSnippets": 5762,
    "sourceReputation": "High",
    "benchmarkScore": 87.87,
    "versions": ["v16.2.9", "v15.1.8", "v14.3.0-canary.87", "..."]
  }

Pass the official library name with punctuation ("Next.js", not "nextjs") and a query describing what you're after — the query ranks the candidates by relevance. Pick the candidate with the highest Source Reputation and Benchmark Score (100 is best) whose owner matches the authoritative repo. To pin a version, append it to the ID: /vercel/next.js/v15.1.8.

Step 2: Fetch Documentation

Tool: query-docs
Input: { "libraryId": "/vercel/next.js", "query": "app router caching with fetch" }
Output: [current documentation for Next.js App Router caching, pulled from official docs]

There is no tokens or topic parameter in v4 — a single, specific query is the only lever on what comes back. Claude Code then uses this documentation to write accurate code — not training-data guesses. You can also skip Step 1 by giving query-docs a library ID directly (in the /org/project or /org/project/version form) when you already know it.

Natural Language Usage

When Context7 is connected to Claude Code, you can reference docs naturally:

"Using the latest React 19 API, implement a transition-based search input."

Claude will automatically call resolve-library-id for React and query-docs for React 19 transitions before writing code.

"Show me how to use Prisma's new omit field in a findMany query."

Claude will fetch current Prisma docs for the omit feature.

You can also nudge Context7 explicitly from a prompt: end a request with use context7, or name a known ID with use library /supabase/supabase, or just mention a version ("Next.js 14 middleware") and Context7 matches it.


Scoping the query (the only lever in v4)

Earlier versions exposed a tokens budget and a topic string on the docs tool. v4 removed both. The single query string is now your only control over what comes back — so how you phrase it is the whole game.

A tight, single-concept query returns the relevant slice; a vague or multi-topic one wastes the call. The tool's own guidance:

Heuristics for a lean session:

flowchart TD
    A["Need library docs"] --> B["Phrase one specific query"]
    B --> C{"Single concept<br/>or several?"}
    C -->|"single"| D["One query-docs call"]
    C -->|"several"| E["One call per concept<br/>(max 3 per question)"]
    D --> F["Fetch once"]
    E --> F
    F --> G["Reuse in session<br/>do not re-query"]
    G --> H["Next library only when done"]

Gone in v4: the tokens parameter and the DEFAULT_MINIMUM_TOKENS floor that older guides warned about. If you find a CLAUDE.md rule or slash command passing tokens=… or a topic=…, it's referencing the pre-v4 tool — drop those args and put the specificity into the query string instead.


Integration Patterns

Use Context7 in CLAUDE.md

Tell Claude Code to always use Context7 for new library integrations:

CLAUDE.md:

## Documentation Policy

When implementing features using any external library:
1. Always use the Context7 MCP tool `resolve-library-id` to find the library
2. Use `query-docs` to fetch current docs for the specific API you need
3. Write code that matches the fetched documentation exactly
4. Never use remembered API patterns if they differ from fetched docs

This prevents hallucinated or outdated API usage.

Slash Command: Fetch Library Docs

.claude/commands/docs.md:

Fetch the current documentation for library $ARGUMENTS.

1. Use the Context7 MCP tool `resolve-library-id` with libraryName "$ARGUMENTS" and a
   `query` describing the feature you need
2. Use `query-docs` with the resolved `libraryId` and a specific, single-concept `query`
3. Display the documentation summary and key API patterns
4. Identify any breaking changes from previous versions if mentioned

This gives you current, accurate docs to work from.

Usage: /project:docs drizzle-orm

Pre-Implementation Research Pattern

For any new library integration inside Claude Code:

You: "Implement file uploads using uploadthing in our Next.js app."

Claude (with Context7):
1. Calls resolve-library-id(libraryName="UploadThing", query="nextjs app router uploads") → gets current ID
2. Calls query-docs(libraryId, query="nextjs app router file upload route") → gets current upload patterns
3. Writes code using the exact current API
4. No hallucinated deprecated patterns

Environment Variables

# Recommended (for higher rate limits) — free key from context7.com/dashboard
CONTEXT7_API_KEY=...

# The stdio server reads CONTEXT7_API_KEY automatically, or takes --api-key <key>
# (the flag wins if both are set). The hosted server (mcp.context7.com/mcp) takes
# it as an "Authorization: Bearer <key>" header. `npx ctx7 setup` provisions one
# for you via OAuth. No key is strictly required — the free tier just rate-limits
# harder.

Keep CONTEXT7_API_KEY in .env.local / your environment manager — never commit it (see ENV_MASTER.md).


Supported Libraries

Context7 covers thousands of libraries. Key examples relevant to this tech stack:

Library Context7 ID
Next.js /vercel/next.js
React /facebook/react
Supabase JS /supabase/supabase-js
Prisma /prisma/prisma
Drizzle ORM /drizzle-team/drizzle-orm
Clerk /clerk/javascript
Anthropic SDK /anthropic/anthropic-sdk-js
OpenAI SDK /openai/openai-node
Tailwind CSS /tailwindlabs/tailwindcss
Zod /colinhacks/zod
Hono /honojs/hono

Find more: run resolve-library-id with any library name — Context7 will find it.


Automation Workflows

Claude Code Hook: Auto-check Docs on Install

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'npm install|pnpm add|yarn add'; then echo 'Library installed — use Context7 MCP to fetch current docs before coding'; fi"
          }
        ]
      }
    ]
  }
}

Combined CLAUDE.md + Context7 Workflow

# CLAUDE.md — Context7 Integration

## New Dependency Rule

When you add a new npm package:
1. Use `resolve-library-id` to find it in Context7
2. Fetch docs with `query-docs` (query: the specific feature area)
3. Implement using the documented API
4. Note the version in a comment if the API may change

## Libraries Pre-approved (already docs-fetched)
- Next.js 15 (App Router)
- Supabase JS v2
- Clerk v6
- Drizzle ORM v0.40

Common Use Cases

Use Case Approach
New library integration resolve-library-id → query-docs
Migration between versions query-docs with query "v2 to v3 migration"
Checking breaking changes query-docs with query "breaking changes changelog"
Finding correct type signatures query-docs with query "typescript types for X"
Edge case API details query-docs with a specific query, e.g. "error handling"

Troubleshooting

Issue Fix
Library not found Try alternate names: "next" → "Next.js", "react-query" → "tanstack query"
Docs seem outdated Pin the version in the ID: query-docs("/vercel/next.js/v15.1.8", query="…")
Rate limit hit Add CONTEXT7_API_KEY (or run npx ctx7 setup) for higher limits
MCP not connecting Run claude mcp list to verify Context7 is registered
Docs too broad / off-target Tighten the query to a single concept (v4 has no tokens/topic knob)
get-library-docs not found It was renamed to query-docs in v4 — update the call

Best practice: Always combine Context7 with a CLAUDE.md rule that mandates doc lookup before implementing any new library feature. This makes hallucinated APIs structurally impossible in your workflow.

Official docs:

Cursor Integration Guide

Technology: cursor · Category: tooling · Last reviewed: 2026-09-27

Source: https://tech-stack.codeamanilabs.org/guide/cursor

Insight:

Cursor is a VS Code fork built around an agent, not an autocomplete plugin — Tab, Ctrl+K inline edit, and a multi-file Agent all share one index of your repo. On Windows the whole experience hinges on one setup decision: install Cursor on Windows, keep the repo on the Linux side of WSL, and let the anysphere.remote-wsl extension run the extension host, terminal, and agent inside Ubuntu — otherwise every command the agent runs crosses the 9P boundary and crawls. For codeAmani it needs no new config file: Cursor reads CLAUDE.md exactly the way it reads AGENTS.md and always applies it, so this repo's conventions are already the agent's rules.

 ██████╗██╗   ██╗██████╗ ███████╗ ██████╗ ██████╗
██╔════╝██║   ██║██╔══██╗██╔════╝██╔═══██╗██╔══██╗
██║     ██║   ██║██████╔╝███████╗██║   ██║██████╔╝
██║     ██║   ██║██╔══██╗╚════██║██║   ██║██╔══██╗
╚██████╗╚██████╔╝██║  ██║███████║╚██████╔╝██║  ██║
 ╚═════╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝ ╚═════╝ ╚═╝  ╚═╝

Cursor Integration Guide

Focus: Using Cursor as the day-to-day editor for a WSL Ubuntu dev box driven from Windows — the remote-WSL connection, the three AI surfaces (Tab, Ctrl+K, Agent), rules that reuse this repo's CLAUDE.md, MCP servers, the agent CLI, and scripting the agent from code with the Cursor TypeScript SDK (@cursor/sdk). Grounded in cursor.com/docs; reviewed 2026-09-27.

Overview

Cursor is an AI code editor from Anysphere, built as a fork of VS Code. Because it's a fork, everything you know about VS Code still holds — keybindings, settings JSON, the command palette, the extension host model, and remote development. What's bolted on is a coding agent that shares one index of your codebase across three distinct surfaces.

The three surfaces are worth separating in your head, because they behave differently and honour different config:

Surface Shortcut Scope Reads rules?
Tab Tab to accept Autocomplete + multi-line + cross-file jumps No
Inline edit (Cmd-K) Ctrl+K (Win/Linux), Cmd+K (Mac) The selection you highlighted No
Agent Ctrl+I / Ctrl+L Whole repo, multi-file, runs terminal commands Yes
agent CLI terminal Whole repo, headless-capable, CI-friendly Yes
@cursor/sdk your TypeScript Local tree or cloud VM, programmatic Yes (local: with settingSources)

And it is a distinct product from the two neighbours already documented here:

Cursor VS Code Visual Studio
What it is VS Code fork, agent-first Microsoft's editor Microsoft's full Windows IDE
Extension registry Open VSX via Cursor's marketplace proxy Microsoft Marketplace VSIX / NuGet
WSL extension anysphere.remote-wsl (first-party rebuild) ms-vscode-remote.remote-wsl n/a — Windows-native
Agent config .cursor/rules/*.mdc, AGENTS.md, CLAUDE.md per-extension per-extension
flowchart LR
  A["Your repo"] --> B["Cursor's codebase index"]
  B --> C["Tab<br/>autocomplete + jumps"]
  B --> D["Ctrl+K<br/>inline edit on selection"]
  B --> E["Agent<br/>multi-file + terminal"]
  B --> F["agent CLI<br/>terminal + CI"]
  G[".cursor/rules/*.mdc<br/>AGENTS.md · CLAUDE.md"] --> E
  G --> F
  H[".cursor/mcp.json<br/>MCP servers"] --> E
  H --> F
  I[".cursorignore"] --> B

See also: wsl/ for the WSL platform itself (install, .wslconfig, the filesystem rule, systemd) and visual-studio/ for the unrelated .NET IDE. This guide only covers Cursor.

Official Documentation

Resource URL
Docs home https://cursor.com/docs
Quickstart https://cursor.com/docs/get-started/quickstart
Rules (.cursor/rules, AGENTS.md) https://cursor.com/docs/rules
MCP in Cursor https://cursor.com/docs/mcp
CLI install https://cursor.com/docs/cli/installation
Agents Window (Cursor 3) https://cursor.com/docs/agent/agents-window
Ignore file reference https://cursor.com/docs/reference/ignore-file
TypeScript SDK https://cursor.com/docs/sdk/typescript
Download https://cursor.com/download
Machine-readable sitemap https://cursor.com/llms.txt

Every docs page has a .md twin — append .md to any URL (https://cursor.com/docs/rules.md) to get clean markdown. https://cursor.com/llms.txt lists all of them.


Install

Install the editor on Windows, not inside the distro. Cursor is a GUI app; the Linux side only ever runs its headless server.

# 1. Editor — download the Windows .exe from https://cursor.com/download and run it.
#    (macOS: .dmg · Linux: apt/dnf repo or AppImage)

# 2. CLI agent — run this INSIDE your WSL Ubuntu shell
curl https://cursor.com/install -fsS | bash
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
agent --version
# CLI on native Windows PowerShell (only if you also want it outside WSL)
irm 'https://cursor.com/install?win32=true' | iex

The CLI's install docs list its supported targets as "macOS, Linux and Windows (WSL)" — WSL is a first-class install target for the agent binary even though the editor is Windows-native.


Cursor + WSL Ubuntu

This is the part with no official docs page, so it's where most of the time gets lost. The mechanism is inherited wholesale from VS Code Remote: the UI runs on Windows, a server runs inside the distro, and everything that touches your code — extension host, language servers, integrated terminal, and the agent's shell commands — executes on the Linux side.

flowchart TB
  subgraph WIN["Windows"]
    U["Cursor UI<br/>renderer + Tab client"]
    X["UI extensions<br/>themes, keymaps"]
  end
  subgraph LIN["WSL · Ubuntu"]
    S["Cursor server<br/>~/.cursor-server"]
    W["Workspace extensions<br/>ESLint, Prettier, Tailwind"]
    T["Integrated terminal<br/>bash · node · pnpm · git"]
    R["Your repo<br/>/home/you/code/app"]
  end
  U <-->|"anysphere.remote-wsl"| S
  S --> W
  S --> T
  T --> R
  W --> R
  R -.->|"AVOID: /mnt/c crossing<br/>9P protocol, ~20x slower"| WIN

1. Put the repo on the Linux filesystem

Non-negotiable, and the single biggest performance lever (see wsl/ §5):

# GOOD — native ext4, full speed
mkdir -p ~/code && cd ~/code
git clone git@github.com:codeAmani-Labs/your-app.git
cd your-app

# BAD — /mnt/c/... crosses the 9P boundary on every stat(), and the agent
# stats a LOT. npm install and next dev will crawl.

2. Set the distro Cursor will attach to

Cursor connects to the default WSL distribution:

wsl --list --verbose
wsl --set-default Ubuntu-24.04

3. Connect

Three routes, all equivalent:

# From a WSL shell — needs the PATH shim from step 4
cd ~/code/your-app
cursor .

On first connect Cursor prompts to install anysphere.remote-wsl — Cursor's own rebuild of the WSL extension. Accept it. It installs the server into ~/.cursor-server inside the distro. Cursor ships first-party Anysphere replacements for Microsoft-Marketplace-only extensions precisely because Cursor's marketplace is backed by Open VSX, and ms-vscode-remote.remote-wsl is not on Open VSX.

4. The cursor shell shim

cursor . from inside WSL is the fastest way in, but the shim lives in the Windows install and isn't on the Linux PATH by default:

# ~/.bashrc — adjust <WINUSER> to your Windows username
export PATH="$PATH:/mnt/c/Users/<WINUSER>/AppData/Local/Programs/cursor/resources/app/bin"
source ~/.bashrc
cd ~/code/your-app && cursor .          # opens Windows Cursor, attached to WSL
cursor --disable-extensions             # bisect a slow/conflicting extension

5. Verify you are actually remote

Cheap check, and worth doing before you blame the agent for anything:

# In Cursor's integrated terminal (Ctrl+`)
uname -a          # expect: Linux ... microsoft-standard-WSL2
pwd               # expect: /home/you/code/your-app  — NOT /mnt/c/...
which node        # expect: /home/you/.nvm/... or /usr/bin/node — NOT /mnt/c/...

The status bar should read WSL: Ubuntu-24.04. If which node resolves to /mnt/c/..., Windows binaries are leaking onto the Linux PATH via interop and the agent will invoke node.exe across the filesystem boundary — the classic 9P thrashing symptom. Install Node inside the distro:

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node -v && npm -v

Or drop appendWindowsPath = false into /etc/wsl.conf and restart the distro to stop Windows PATH inheritance entirely.

6. Install extensions on the right side

Same split as VS Code: UI extensions (themes, keymaps) install on Windows; workspace extensions (ESLint, Prettier, Tailwind IntelliSense, the TypeScript server) must be installed in the WSL: Ubuntu scope. Open the Extensions panel (Ctrl+Shift+X) while connected and look for the Install in WSL: Ubuntu-24.04 button. An ESLint installed only on the Windows side will silently lint nothing.

7. Cursor 3: use the Editor window, not the Agents Window

Cursor 3 (released 2 April 2026) added the Agents Window — an agent-first shell for running parallel local/cloud agents across repos. It supports local, cloud, and remote SSH environments; WSL connections are not supported there yet. If you land in the Agents Window and see "Extension 'WSL' is required to open the remote window", switch back:

The classic editor is also the right choice when you want VS Code extensions and split panes, which is exactly the Next.js/Tailwind workflow.

WSL troubleshooting

Symptom Cause / fix
Extension 'WSL' is required to open the remote window You're in the Agents Window — it can't do WSL yet. Switch to the editor (Open IDE).
Everything is slow, disk pegged Repo on /mnt/c, or Windows binaries on the Linux PATH. Move to ~/code; set appendWindowsPath = false.
cursor: command not found in WSL Add the Windows resources/app/bin to PATH (step 4).
Connects to the wrong distro wsl --set-default Ubuntu-24.04, then reconnect.
Extension "does nothing" Installed on the Windows side only — reinstall into the WSL: Ubuntu scope.
Agent prompts time out only in WSL windows Known anysphere.cursor-agent-exec extension-host issue in remote windows. Reload the window; fall back to the agent CLI in the integrated terminal, which is unaffected.
Remote server wedged Close the window, wsl --shutdown from PowerShell, reopen. Nuke ~/.cursor-server to force a clean server reinstall.

The three AI surfaces

Tab — autocomplete that moves

Grey ghost text ahead of the caret. Tab accepts, Esc rejects, Ctrl+→ accepts word-by-word. Two behaviours worth knowing:

Toggle it from the Tab status indicator (bottom-right): snooze for a duration, disable globally, or disable per file extension — turning it off for markdown and json is the usual first move.

Ctrl+K — inline edit on a selection

1. Select the code
2. Ctrl+K  (Cmd+K on Mac)
3. "Convert this to a server action and validate the body with zod"
4. Enter → applied in place; type a follow-up and Enter again to refine
5. Alt+Enter switches to question mode instead of edit mode

Ctrl+L on a selection promotes it into Agent with that code as context — the escape hatch when a "quick edit" turns out to be multi-file.

Agent — the multi-file worker

Ctrl+I opens the panel. Four modes, cycled with Shift+Tab (or Ctrl+. for the menu):

Mode Use for Edits files?
Agent Building, refactoring, fixing Yes
Ask Understanding architecture No (read-only)
Plan Multi-file features you want to review first Yes, after you approve the plan
Debug Bugs needing runtime evidence Yes

Ctrl+/ cycles models. Hover any prior message → Restore Checkpoint rolls the working tree back to that point. Queue follow-ups while it works; drag to reorder. Custom subagents are markdown files in .cursor/agents/.

When connected to WSL, every terminal command the Agent runs executes inside Ubuntu against your Linux toolchain. That's the whole point of the setup: pnpm dev, npx supabase, psql, and git all behave the way CI does.


Rules — and why codeAmani needs almost none

Cursor has four rule sources, applied in precedence order Team → Project → User:

Source Where Scope
Project rules .cursor/rules/*.mdc Version-controlled, glob-scoped
AGENTS.md / CLAUDE.md repo root Always applied, every conversation
User rules Cursor Settings, or ~/.cursor/rules Your machine / your account
Team rules Cursor dashboard Team + Enterprise plans

The one fact that saves you a file: Cursor reads CLAUDE.md the same way it reads AGENTS.md, picks it up automatically from the project root, and applies it to every conversation regardless of alwaysApply frontmatter. This repo already has a CLAUDE.md full of conventions — TypeScript everywhere, named exports, execFileSync(cmd, [args]), Stripe-by-default payments, webhook signature verification. Cursor is already reading it. Do not duplicate it into a rules file; duplication is how the two drift.

Use .cursor/rules/*.mdc only for the thing CLAUDE.md can't do: glob-scoped rules.

---
globs: app/**/*.tsx, app/**/*.ts
alwaysApply: false
---

- Server Components by default. Add "use client" only for interactivity.
- Never import lib/stripe.ts or lib/supabase.ts from a client component —
  it drags the secret key into the bundle.
- Route handlers validate the body with zod before touching the database.
- Follow the file layout in CLAUDE.md: app/api/stripe/*, app/api/webhooks/*.
---
globs: app/api/webhooks/**, app/api/mpesa/**
alwaysApply: false
---

- Verify the signature BEFORE parsing or trusting the payload
  (Stripe signing secret; Svix for Clerk; callback validation for M-Pesa).
- Read the raw body — never a pre-parsed JSON object.
- Handlers are idempotent: dedupe on the provider's event id.

Frontmatter drives when a rule loads:

alwaysApply description globs Behaviour
true — — Always included
false — provided Auto-attached when a matching file is in context
false provided — Agent pulls it in when the description looks relevant
false — — Only when you @-mention it

Rule files must use .mdc. A .md file inside .cursor/rules/ is silently ignored. Create them with /create-rule in chat rather than by hand.

.cursorrules is legacy. The single root-level .cursorrules file still works but is documented as deprecated — migrate its contents into a .cursor/rules/*.mdc rule set to Always Apply, then delete it. If you're starting today, skip it entirely.

Rules apply to Agent only. Not to Tab, not to inline edit, not to Bugbot PR reviews. Style conventions you actually want enforced belong in ESLint/Prettier, which run in the WSL extension host and gate the build.

.cursorignore

Sits next to .gitignore (which Cursor already respects) and blocks files from indexing and from the agent:

node_modules/
.next/
dist/
*.min.js
.env*
reports/
packages/dashboard/lib/content.generated.json

.env files, .git/, and lock files are excluded by default. Treat it as a noise filter, not a security boundary — Cursor's own docs say so, and terminal commands plus MCP tools run outside Cursor's file-access controls and can still read ignored files. Secrets belong in .env.local and Hazina, never in the tree.


MCP servers in Cursor

Cursor speaks MCP with three transports — stdio (local, Cursor spawns it), SSE, and Streamable HTTP (remote, OAuth) — and supports tools, prompts, resources, roots, elicitation, and the MCP Apps UI extension.

Config Path Scope
Project .cursor/mcp.json This repo
Global ~/.cursor/mcp.json Everywhere
{
  "mcpServers": {
    "context7": {
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp"]
    },
    "tech-stack": {
      "command": "node",
      "args": ["${workspaceFolder}/packages/mcp-server/dist/index.js"]
    },
    "supabase": {
      "url": "https://mcp.supabase.com/mcp",
      "headers": { "Authorization": "Bearer ${env:SUPABASE_ACCESS_TOKEN}" }
    }
  }
}

Interpolation resolves in command, args, env, url, and headers: ${env:NAME}, ${userHome}, ${workspaceFolder}, ${workspaceFolderBasename}, ${pathSeparator} / ${/}.

WSL gotcha: when the window is remote, ${userHome} and ~/.cursor/mcp.json resolve to the Linux home, and command runs in the Linux shell. A stdio server configured with a Windows path (C:\... or node.exe) will not start. Install MCP server dependencies inside the distro and use POSIX paths. ${workspaceFolder} is the folder containing .cursor/mcp.json, so project-scoped config travels correctly.

For servers that hand you a fixed Client ID instead of supporting dynamic registration (Figma, Linear), add a static auth block:

{
  "mcpServers": {
    "figma": {
      "url": "https://mcp.figma.com/mcp",
      "auth": { "CLIENT_ID": "your-client-id", "scopes": ["read"] }
    }
  }
}

See MASTER_MCP_CONFIG.md for the canonical codeAmani server list — the same mcpServers shape drops straight into .cursor/mcp.json.


The agent CLI

Same agent, same modes, in a terminal — which in this setup means inside WSL, so it inherits the Linux toolchain automatically. It also sidesteps the remote extension-host flakiness entirely.

agent                                     # interactive session
agent "refactor lib/stripe.ts to use the 2026 Checkout API"
agent --mode=plan "add M-Pesa STK push to the checkout route"
agent --mode=ask "where does the webhook signature get verified?"

agent ls            # list past chats
agent resume        # resume the latest
agent --continue    # continue the previous session
agent update        # upgrade in place

Headless / CI:

export CURSOR_API_KEY=...                 # from https://cursor.com/dashboard/api
agent -p "review these changes for security issues" --output-format text
agent -p --force "add JSDoc to lib/domain/dispatch.ts"   # --force = apply, not propose

Without --force, print mode only proposes changes. /sandbox (or --sandbox enabled|disabled) controls command execution and network access; when a command needs sudo, the CLI shows a masked prompt and pipes the password straight to sudo over IPC — the model never sees it. Prefix a message with & to hand the conversation off to a Cloud Agent.

Terminal config lives at ~/.cursor/ in the distro; system-wide hooks at /etc/cursor/hooks.json on Linux/WSL. If Shift+Enter doesn't insert a newline in Windows Terminal, run /setup-terminal, or use Ctrl+J — the universal fallback that survives tmux and SSH.


Cursor TypeScript SDK — @cursor/sdk

The same agent that runs in the IDE, the agent CLI, and Cursor Web is callable from your own TypeScript. Reach for it when the agent should be triggered by code, not a person: CI auto-fix bots, bug-triage workers, code-review passes, repo-wide codemods, or an agent embedded in a product. Source: cursor.com/docs/sdk/typescript (.md twin available); runnable examples in the Cursor Cookbook.

Two runtimes, one interface

Runtime Where the agent loop runs Files come from Use when
Local (local: {...}) Inline in your Node process Your disk (local.cwd) Dev scripts, CI checks against a checked-out tree
Cloud (cloud: {...}) Isolated Cursor-hosted VM Repo cloned into the VM Caller has no checkout, many agents in parallel, runs must survive disconnects, auto-PRs

"Local" is the agent loop, not the model. Inference always goes through Cursor's hosted models in both modes. Local only keeps files and tool execution on your machine.

The runtime is picked by which key you pass to Agent.create(). Both use the same CURSOR_API_KEY. Local IDs look like agent-<uuid>, cloud IDs like bc-<uuid>, and Agent.resume(id) auto-detects the runtime from the prefix.

Concept What it is
Agent Durable container: conversation state, workspace config, settings. Survives many prompts.
Run One prompt submission (agent.send()), with its own stream, status, result, and cancel.
SDKMessage Normalized stream event, same shape for both runtimes.

Install + auth

npm install @cursor/sdk            # scoped — the bare "cursor/sdk" does not exist
export CURSOR_API_KEY="..."        # user key: cursor.com/dashboard/api
                                   # service-account key: cursor.com/dashboard/team-settings

Quick start — local agent, streamed

import { Agent } from "@cursor/sdk";

await using agent = await Agent.create({
  apiKey: process.env.CURSOR_API_KEY!,
  model: { id: "composer-2.5" },
  local: { cwd: process.cwd() },
});

const run = await agent.send("Find the bug in lib/stripe.ts");

for await (const event of run.stream()) {
  switch (event.type) {
    case "assistant":
      for (const block of event.message.content) {
        if (block.type === "text") process.stdout.write(block.text);
      }
      break;
    case "tool_call":
      console.error(`[tool] ${event.name}: ${event.status}`);
      break;
  }
}

// Same agent, conversation context carries over.
const fix = await agent.send("Fix it and add a regression test");
const result = await fix.wait();
console.log(result.status, result.result, result.usage?.totalTokens);

⚠ Headless means auto-approve. A default local agent runs shell, edit, and write tool calls with no human in the loop. Gate it before pointing it at anything real. Options are listed under Guardrails below.

Cloud agent that opens a PR

import { Agent } from "@cursor/sdk";

const agent = await Agent.create({
  apiKey: process.env.CURSOR_API_KEY!,
  model: { id: "composer-2.5" },
  name: "dependabot-triage",
  cloud: {
    repos: [{ url: "https://github.com/codeAmani-Labs/your-app", startingRef: "main" }],
    autoCreatePR: true,
    metadata: { ticket_id: "ENG-456" },            // your own tags, returned by Agent.list/get
    envVars: { STAGING_API_TOKEN: process.env.STAGING_API_TOKEN! }, // encrypted, deleted with the agent
  },
});

const run = await agent.send("Upgrade next to the latest 15.x patch and fix any type errors");
const result = await run.wait();
console.log(result.git?.branches[0]?.prUrl);

Guardrails for headless runs

Knob Scope What it does
tools: ["read", "grep", "glob", "ls"] local Allowlist built-in tools; [] = text-only
disallowedTools: ["shell"] local Deny list; deny wins over tools. "mcp" also removes custom tools, "task" disables subagents
local.sandboxOptions: { enabled: true } local Writes confined to cwd + temp; outbound network denied except hosts in .cursor/sandbox.json; bubblewrap on Linux/WSL
local.autoReview: true local Routes Shell/MCP/Fetch calls through the IDE's Auto-review classifier; blocked calls are denied, not escalated. Best-effort, not a security boundary
.cursor/hooks.json local + cloud File-based policy (beforeShellExecution, preToolUse, …). There is no programmatic hook callback
Cloud VM cloud Always isolated; sandboxOptions doesn't apply

tools, disallowedTools, and systemPrompt are not persisted, so pass them again on Agent.resume(). Stack the layers. A CI review bot should be read-only and sandboxed:

const reviewer = await Agent.create({
  apiKey: process.env.CURSOR_API_KEY!,
  model: { id: "composer-2.5" },
  tools: ["read", "grep", "glob", "ls"],
  local: { cwd: process.cwd(), sandboxOptions: { enabled: true }, settingSources: ["project"] },
});

settingSources: ["project"] makes the local agent load this repo's .cursor/ config, which includes rules, .cursor/mcp.json, and .cursor/agents/*.md. Without it, only inline config is loaded. Cloud agents always load project/team/plugins and ignore the field.

Custom tools — your functions, no MCP server

const agent = await Agent.create({
  apiKey: process.env.CURSOR_API_KEY!,
  model: { id: "composer-2.5" },
  local: {
    cwd: process.cwd(),
    customTools: {
      get_deploy_status: {
        description: "Current Vercel deployment status for a project.",
        inputSchema: {
          type: "object",
          properties: { project: { type: "string" } },
          required: ["project"],
        },
        annotations: { readOnlyHint: true },
        async execute({ project }) {
          const res = await fetch(`https://internal.example/deploys/${project}`);
          return await res.json();          // string | JSON | { content, isError?, structuredContent? }
        },
      },
    },
  },
});

Custom tools are registered as an MCP server named custom-user-tools, reach subagents, run in your process (so they can use anything your code can), and skip interactive approval. Treat each one like a public API route: validate args, and keep secrets in the closure, never in the return value. Local agents only; cloud rejects them.

MCP servers and subagents

await Agent.create({
  apiKey: process.env.CURSOR_API_KEY!,
  model: { id: "composer-2.5" },
  local: { cwd: process.cwd() },
  mcpServers: {
    context7: { type: "stdio", command: "npx", args: ["-y", "@upstash/context7-mcp"] },
    supabase: {
      type: "http",
      url: "https://mcp.supabase.com/mcp",
      headers: { Authorization: `Bearer ${process.env.SUPABASE_ACCESS_TOKEN!}` },
    },
  },
  agents: {
    "security-reviewer": {
      description: "Reviews diffs for secrets, injection, and missing webhook verification.",
      prompt: "Follow CLAUDE.md security rules. Report findings; do not edit.",
      model: "inherit",
    },
  },
});

Models, cost, and errors

Known limitations (as of SDK 1.0.x)


codeAmani notes


Troubleshooting

Issue Fix
Blank screen on startup Restart; on Windows run as administrator; Ctrl+Shift+P → Clear Editor History
Update stuck Ctrl+Shift+P → Cursor: Attempt Update, restart
Tab suggesting nothing Check the Tab status indicator — it may be snoozed or disabled for that file extension
A rule "isn't working" Rules apply to Agent only. Also confirm .mdc, not .md, and that globs actually match
MCP server won't start in a WSL window The command runs in the Linux shell — POSIX paths and Linux-installed deps only
Editor sluggish cursor --disable-extensions, then re-enable one at a time
Want the raw docs Append .md to any docs URL; full index at https://cursor.com/llms.txt

Official docs:

Daraja API (Safaricom M-Pesa) Integration Guide

Technology: daraja-api · Category: payments · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/daraja-api

Insight:

Daraja is M-Pesa — codeAmani's payment rail for Kenya-targeted projects (Stripe stays the default elsewhere), and there it's the primary rail, not an afterthought. The non-negotiables: phone as 254… (no +), integer KES, 1-hour token refresh, HTTPS callbacks, and idempotency on CheckoutRequestID. Offload slow post-payment work to a queue (Upstash QStash) so the callback returns fast. Since ~Mar 2026, payer numbers are masked — don't build identity on the callback phone.

██████╗  █████╗ ██████╗  █████╗      ██╗ █████╗
██╔══██╗██╔══██╗██╔══██╗██╔══██╗     ██║██╔══██╗
██║  ██║███████║██████╔╝███████║     ██║███████║
██║  ██║██╔══██║██╔══██╗██╔══██║██   ██║██╔══██║
██████╔╝██║  ██║██║  ██║██║  ██║╚█████╔╝██║  ██║
╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚════╝ ╚═╝  ╚═╝

Daraja API (Safaricom M-Pesa) Integration Guide

Focus: Integrating M-Pesa mobile money payments into projects from Claude Code — STK Push, C2B, B2C, and webhook handling — using the Safaricom Daraja API.

Overview

Daraja is Safaricom's developer platform for M-Pesa, Kenya's leading mobile money network. It provides REST APIs for sending payment prompts (STK Push / Lipa na M-Pesa Online), business-to-customer transfers (B2C), customer-to-business collection (C2B), recurring payments / standing orders (the newer Ratiba API), account balance queries, and transaction status checks. Claude Code can scaffold, test, and automate M-Pesa payment integrations — including sandbox testing, OAuth token management, and webhook verification.

Portal note (Daraja 3.0, launched Nov 2025). Safaricom rebuilt the developer portal as Daraja 3.0 — fully self-service registration, a redesigned dashboard, and new lowercase URLs. The old capitalised deep links (/APIs, /Documentation, /test_credentials, /c2b/apis/post/registerurl) now 404. The API endpoints themselves are unchanged (STK Push is still …/mpesa/stkpush/v1/processrequest); only the portal navigation moved. Create apps and read sandbox credentials under Dashboard, and browse per-API docs under APIs.

Here is the big picture at a glance — you have got this once you see how the pieces connect:

flowchart TD
  A["Your app"] --> B["Get OAuth token"]
  B --> C["STK Push request"]
  C --> D["Daraja API"]
  D --> E["Customer phone prompt"]
  E --> F["Callback to your webhook"]
  F --> G["Save payment to database"]

Official Documentation

Resource URL
Daraja Developer Portal (Daraja 3.0) https://developer.safaricom.co.ke
API catalogue & per-API docs https://developer.safaricom.co.ke/apis
Dashboard (apps, keys, sandbox credentials) https://developer.safaricom.co.ke/dashboard
FAQs https://developer.safaricom.co.ke/faqs

Individual API pages (M-Pesa Express / STK Push, C2B, B2C, Authorization, Ratiba) live as client-routed pages under /apis in the Daraja 3.0 SPA — reach them from the API catalogue rather than deep-linking, since the old fixed doc URLs were retired in the portal rebuild.


Authentication

Daraja uses OAuth2 client credentials flow. Every API call requires a Bearer token obtained by encoding your Consumer Key and Secret as Base64.

Get a Token

// lib/mpesa-auth.ts
export async function getMpesaToken(): Promise<string> {
  const credentials = Buffer.from(
    `${process.env.MPESA_CONSUMER_KEY}:${process.env.MPESA_CONSUMER_SECRET}`
  ).toString("base64");

  const url =
    process.env.MPESA_ENV === "production"
      ? "https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials"
      : "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials";

  const res = await fetch(url, {
    headers: { Authorization: `Basic ${credentials}` },
  });

  const data = await res.json();
  if (!data.access_token) throw new Error("Failed to get M-Pesa token");
  return data.access_token;
}

cURL Token Request

# Sandbox token
TOKEN=$(curl -s "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials" \
  -u "$MPESA_CONSUMER_KEY:$MPESA_CONSUMER_SECRET" | jq -r '.access_token')
echo "Token: $TOKEN"

Core API Integration

STK Push (Lipa na M-Pesa Online / Customer-Initiated Payment)

STK Push sends a payment prompt directly to the customer's phone.

Follow the full lifecycle below — each step is straightforward once you trace the order:

sequenceDiagram
  participant App as "Your server"
  participant Auth as "Daraja OAuth"
  participant Daraja as "Daraja API"
  participant Phone as "Customer phone"
  App->>Auth: Request Bearer token
  Auth-->>App: access_token
  App->>Daraja: STK Push processrequest
  Daraja-->>App: CheckoutRequestID
  Daraja->>Phone: Payment prompt
  Phone-->>Daraja: Enter M-Pesa PIN
  Daraja->>App: Callback with ResultCode
  App->>App: Reconcile and save payment
// lib/mpesa-stk.ts
import { getMpesaToken } from "./mpesa-auth";

function generatePassword(shortcode: string, passkey: string, timestamp: string): string {
  return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString("base64");
}

export async function stkPush({
  phone,
  amount,
  accountReference,
  transactionDesc,
}: {
  phone: string;        // Format: 254XXXXXXXXX
  amount: number;       // Amount in KES (integer)
  accountReference: string;
  transactionDesc: string;
}) {
  const token = await getMpesaToken();
  const timestamp = new Date()
    .toISOString()
    .replace(/[^0-9]/g, "")
    .slice(0, 14); // YYYYMMDDHHmmss

  const shortcode = process.env.MPESA_SHORTCODE!;
  const passkey = process.env.MPESA_PASSKEY!;
  const password = generatePassword(shortcode, passkey, timestamp);

  const baseUrl =
    process.env.MPESA_ENV === "production"
      ? "https://api.safaricom.co.ke"
      : "https://sandbox.safaricom.co.ke";

  const res = await fetch(`${baseUrl}/mpesa/stkpush/v1/processrequest`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      BusinessShortCode: shortcode,
      Password: password,
      Timestamp: timestamp,
      TransactionType: "CustomerPayBillOnline",
      Amount: amount,
      PartyA: phone,
      PartyB: shortcode,
      PhoneNumber: phone,
      CallBackURL: `${process.env.APP_URL}/api/mpesa/callback`,
      AccountReference: accountReference,
      TransactionDesc: transactionDesc,
    }),
  });

  return res.json();
}

STK Push Status Query

export async function checkStkStatus(checkoutRequestId: string) {
  const token = await getMpesaToken();
  const timestamp = new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14);
  const password = generatePassword(
    process.env.MPESA_SHORTCODE!,
    process.env.MPESA_PASSKEY!,
    timestamp
  );

  const baseUrl =
    process.env.MPESA_ENV === "production"
      ? "https://api.safaricom.co.ke"
      : "https://sandbox.safaricom.co.ke";

  const res = await fetch(`${baseUrl}/mpesa/stkpushquery/v1/query`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      BusinessShortCode: process.env.MPESA_SHORTCODE,
      Password: password,
      Timestamp: timestamp,
      CheckoutRequestID: checkoutRequestId,
    }),
  });

  return res.json();
}

Webhook Handler (Callback URL)

// app/api/mpesa/callback/route.ts (Next.js App Router)
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();

  const result = body.Body?.stkCallback;
  if (!result) return NextResponse.json({ error: "Invalid payload" }, { status: 400 });

  const { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc, CallbackMetadata } = result;

  if (ResultCode === 0) {
    // Payment successful
    const items = CallbackMetadata?.Item ?? [];
    const amount = items.find((i: any) => i.Name === "Amount")?.Value;
    const mpesaCode = items.find((i: any) => i.Name === "MpesaReceiptNumber")?.Value;
    const phone = items.find((i: any) => i.Name === "PhoneNumber")?.Value;

    // Save to database
    await db.payments.create({
      data: {
        checkoutRequestId: CheckoutRequestID,
        merchantRequestId: MerchantRequestID,
        mpesaCode,
        phone: String(phone),
        amount: Number(amount),
        status: "success",
      },
    });

    console.log(`Payment success: ${mpesaCode} — KES ${amount} from ${phone}`);
  } else {
    // Payment failed or cancelled
    console.log(`Payment failed: ${ResultDesc} (code: ${ResultCode})`);
    await db.payments.updateMany({
      where: { checkoutRequestId: CheckoutRequestID },
      data: { status: "failed", failureReason: ResultDesc },
    });
  }

  return NextResponse.json({ ResultCode: 0, ResultDesc: "Success" });
}

B2C (Business to Customer Payment)

export async function b2cPayment({
  phone,
  amount,
  remarks,
}: {
  phone: string;
  amount: number;
  remarks: string;
}) {
  const token = await getMpesaToken();

  const res = await fetch("https://sandbox.safaricom.co.ke/mpesa/b2c/v3/paymentrequest", {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      OriginatorConversationID: `B2C-${Date.now()}`,
      InitiatorName: process.env.MPESA_INITIATOR_NAME,
      SecurityCredential: process.env.MPESA_SECURITY_CREDENTIAL,
      CommandID: "BusinessPayment",
      Amount: amount,
      PartyA: process.env.MPESA_SHORTCODE,
      PartyB: phone,
      Remarks: remarks,
      QueueTimeOutURL: `${process.env.APP_URL}/api/mpesa/b2c/timeout`,
      ResultURL: `${process.env.APP_URL}/api/mpesa/b2c/result`,
    }),
  });

  return res.json();
}

C2B — Register URL, validation & confirmation

C2B (Customer to Business) is for payments the customer initiates themselves — paying your Paybill or Till from the M-Pesa menu or SIM toolkit, without you triggering an STK Push. Before M-Pesa will forward those payments to you, you must register two callback URLs for your shortcode: a Validation URL (called before the money moves — you can accept or reject) and a Confirmation URL (called after the money has moved — record-keeping only).

Trace the flow once and it clicks — registration is a one-time setup, the callbacks fire on every payment:

sequenceDiagram
  participant App as "Your server"
  participant Daraja as "Daraja API"
  participant Customer as "Customer"
  App->>Daraja: RegisterURL · ValidationURL + ConfirmationURL
  Daraja-->>App: ResponseDescription success
  Customer->>Daraja: Pays Paybill or Till
  Daraja->>App: Validation request
  App-->>Daraja: ResultCode 0 accept · or reject
  Daraja->>App: Confirmation payload
  App->>App: Record payment and reconcile

Register the URLs (one-time per shortcode)

// lib/mpesa-c2b.ts
import { getMpesaToken } from "./mpesa-auth";

export async function registerC2BUrls() {
  const token = await getMpesaToken();

  // NOTE: sandbox uses v1; production C2B Register URL should use v2.
  const baseUrl =
    process.env.MPESA_ENV === "production"
      ? "https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl"
      : "https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl";

  const res = await fetch(baseUrl, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      ShortCode: process.env.MPESA_SHORTCODE,
      ResponseType: "Completed", // "Completed" | "Cancelled" — fallback if ValidationURL is unreachable
      ConfirmationURL: `${process.env.APP_URL}/api/mpesa/c2b/confirmation`,
      ValidationURL: `${process.env.APP_URL}/api/mpesa/c2b/validation`,
    }),
  });

  return res.json(); // { OriginatorCoversationID, ConversationID, ResponseDescription }
}

Confirmation callback handler

Confirmation fires after the payment has cleared — you cannot reject here. Record it idempotently (deduplicate on TransID, the M-Pesa receipt) and always return a success ack so Daraja stops retrying.

// app/api/mpesa/c2b/confirmation/route.ts (Next.js App Router)
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();
  // Example payload:
  // {
  //   TransactionType: "Pay Bill",
  //   TransID: "UCB030CBG1",          // M-Pesa receipt — use as idempotency key
  //   TransTime: "20260311161727",    // YYYYMMDDHHmmss
  //   TransAmount: "1.00",
  //   BusinessShortCode: "600991",
  //   BillRefNumber: "account001",     // account number the customer typed
  //   InvoiceNumber: "",
  //   OrgAccountBalance: "4635316.60",
  //   ThirdPartyTransID: "",
  //   MSISDN: "2547...",               // payer phone (masked in sandbox)
  //   FirstName: "John",
  //   MiddleName: "",
  //   LastName: ""
  // }

  await db.payments.upsert({
    where: { mpesaCode: body.TransID },        // idempotent on the receipt number
    update: {},
    create: {
      mpesaCode: body.TransID,
      phone: String(body.MSISDN),
      amount: Number(body.TransAmount),
      accountRef: body.BillRefNumber,
      status: "success",
      source: "c2b",
    },
  });

  // Always ack — non-2xx makes Daraja retry the confirmation.
  return NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
}

The Validation URL (if you accept it) receives the same payload shape before the debit; reply { "ResultCode": 0, "ResultDesc": "Accepted" } to allow, or a rejection code (e.g. { "ResultCode": "C2B00012", "ResultDesc": "Rejected" }) to block.

Gotcha — validation requires opt-in, and ResponseType is your safety net. External (non-STK) validation is not on by default: Safaricom must enable "External Validation" for your shortcode before your ValidationURL is ever called — until then only the Confirmation fires. The ResponseType you register decides what happens when validation is enabled but your endpoint is unreachable or times out: "Completed" tells M-Pesa to auto-complete the payment (safest for collections — you never lose money to a flaky webhook), while "Cancelled" tells it to auto-reject. Start with "Completed" unless you genuinely need to refuse payments in-flight.

Production endpoint note: the C2B Register URL is v2 in production (/mpesa/c2b/v2/registerurl); the v1 path that appears in some Safaricom go-live emails will not work live. Sandbox still uses v1.

Phone-number masking (live since ~24 Mar 2026 — plan around it). Following CBK approval, Safaricom now masks the customer's phone number in merchant-facing M-Pesa notifications (shown like 0722**000*). Confirmed for the SMS/notification channel; whether the Daraja callback MSISDN / PhoneNumber field is also masked is not officially documented — do not assume it stays in the clear. Practical rules: for STK Push you already supplied PhoneNumber in the request, so persist it then and never depend on the callback echoing it back; for C2B (customer-initiated) you have historically relied on the confirmation MSISDN to know who paid — treat that as at-risk and lean on BillRefNumber (the account the customer types) as your primary identity key, plus TransID for idempotency. A recipient can request full sender details within a 24-hour window (via Safaricom's lookup, shortcode 334); that is a manual consumer path, not an API, so design so a masked number never blocks reconciliation.


Environment Variables

# Required
MPESA_CONSUMER_KEY=...          # From Daraja app → Consumer Key
MPESA_CONSUMER_SECRET=...       # From Daraja app → Consumer Secret
MPESA_SHORTCODE=174379          # Paybill or Till number (174379 for sandbox)
MPESA_PASSKEY=...               # From Daraja app → Lipa na Mpesa → Passkey

# Your app URL (for callbacks — must be HTTPS in production)
APP_URL=https://yourapp.com

# Environment toggle
MPESA_ENV=sandbox               # or "production"

# B2C (if using business payments)
MPESA_INITIATOR_NAME=...        # API operator username
MPESA_SECURITY_CREDENTIAL=...   # Encrypted password

Sandbox Test Credentials

Field Value
Shortcode 174379
Test Phone 254708374149
Passkey Available in Daraja sandbox dashboard

Automation Workflows

Claude Code Slash Command: Test STK Push

.claude/commands/mpesa-test.md:

Test an M-Pesa STK Push payment to the sandbox phone number.

Use Bash to run the test:
```bash
curl -s -X POST https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest \
  -H "Authorization: Bearer $(node scripts/get-mpesa-token.js)" \
  -H "Content-Type: application/json" \
  -d @scripts/stk-test-payload.json | jq .

Report the CheckoutRequestID and whether the request was accepted. Then check if the callback was received at /api/mpesa/callback by checking application logs.


Usage: `/project:mpesa-test`

### GitHub Actions: Sandbox Integration Tests

```yaml
# .github/workflows/mpesa-tests.yml
name: M-Pesa Sandbox Tests
on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - name: Run M-Pesa integration tests
        env:
          MPESA_CONSUMER_KEY: ${{ secrets.MPESA_SANDBOX_CONSUMER_KEY }}
          MPESA_CONSUMER_SECRET: ${{ secrets.MPESA_SANDBOX_CONSUMER_SECRET }}
          MPESA_SHORTCODE: "174379"
          MPESA_ENV: sandbox
          APP_URL: https://webhook.site/unique-id   # test webhook receiver
        run: npm run test:mpesa

Common Use Cases

Use Case Approach
Customer payment STK Push → await callback
Recurring / subscription billing Ratiba API (standing orders, Daraja 3.0) — preferred over the old "scheduled STK Push via cron" hack, since Ratiba gets the customer's up-front consent for repeat debits
Refund / payout B2C PaymentRequest
Merchant collection C2B Register URL + simulate
Payment status STK Push Query API
Account balance AccountBalance API

Ratiba is Daraja 3.0's standing-order API (announced alongside the portal rebuild) for repeat/subscription debits the customer authorises once. Its docs are still rolling out on the portal — verify the exact endpoint and request shape on the /apis Ratiba page before building. For one-off charges, STK Push remains the right tool.


Troubleshooting

Issue Fix
Invalid Access Token Token expires after 1 hour — regenerate before each request
CallbackURL unreachable Must be HTTPS; use ngrok for local development: ngrok http 3000
Invalid PhoneNumber Must be format 254XXXXXXXXX (no leading 0 or +)
ResultCode: 1 in callback Customer cancelled or insufficient funds
The initiator information is invalid B2C initiator name/credential mismatch
Sandbox STK not received Use the sandbox test phone 254708374149
Customer phone shows masked (0722**000*) Expected since ~Mar 2026 masking rollout — key reconciliation off BillRefNumber + TransID, and for STK Push store the number you sent
Old doc link 404s (/APIs, /Documentation) Portal moved to Daraja 3.0 (lowercase /apis, /dashboard); browse APIs from the catalogue

Local Webhook Testing with ngrok

# Install ngrok: https://ngrok.com
ngrok http 3000

# Copy the HTTPS URL and set it as your callback:
# APP_URL=https://xxxx.ngrok.io
# Then: CALLBACK_URL=$APP_URL/api/mpesa/callback

Official docs:

DeepSeek Integration Guide

Technology: deepseek · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/deepseek

Insight:

DeepSeek is the budget reasoning tier — its OpenAI-compatible API drops into existing OpenAI / AI-SDK code with just a baseURL + model change. The V4 family (deepseek-v4-flash / deepseek-v4-pro) folds chat and chain-of-thought into a single model with a per-request thinking toggle and a 1M-token context, at a fraction of frontier-model cost. Use deepseek-v4-flash as a cheap fallback for high-volume, cost-sensitive SME workloads where frontier quality isn't required.

██████╗ ███████╗███████╗██████╗ ███████╗███████╗███████╗██╗  ██╗
██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝██╔════╝██║ ██╔╝
██║  ██║█████╗  █████╗  ██████╔╝███████╗█████╗  █████╗  █████╔╝
██║  ██║██╔══╝  ██╔══╝  ██╔═══╝ ╚════██║██╔══╝  ██╔══╝  ██╔═██╗
██████╔╝███████╗███████╗██║     ███████║███████╗███████╗██║  ██╗
╚═════╝ ╚══════╝╚══════╝╚═╝     ╚══════╝╚══════╝╚══════╝╚═╝  ╚═╝

DeepSeek Integration Guide

Focus: Cost-effective AI inference and chain-of-thought reasoning — the DeepSeek-V4 family (deepseek-v4-flash / deepseek-v4-pro) via the OpenAI-compatible SDK.

Overview

DeepSeek's current generation is the V4 family, served under two model IDs: deepseek-v4-flash (fast, very cheap — the default cost tier) and deepseek-v4-pro (higher quality). Both are OpenAI-compatible — swap the base URL and API key, keep the same code — carry a 1M-token context window, and support a dual thinking / non-thinking mode: the old V3-chat / R1-reasoner split is gone, and chain-of-thought is now a per-request toggle on the same model. An experimental multimodal variant, deepseek-v4-flash-vision-exp, adds image input. Used in codeAmani products as a cost-optimization alternative for tasks that don't require Anthropic's highest capability tier.

Migration note: the legacy deepseek-chat (V3) and deepseek-reasoner (R1) IDs were retired after 2026-07-24. Move existing calls to deepseek-v4-flash (drop-in replacement for deepseek-chat) or deepseek-v4-pro with thinking enabled (replacement for deepseek-reasoner).

Here's the big picture — the same OpenAI SDK call points at DeepSeek, picks a tier, and toggles thinking per request:

flowchart LR
  A["Your app code"] --> B["OpenAI SDK<br/>baseURL · api.deepseek.com"]
  B --> C{"Which tier?"}
  C -->|"deepseek-v4-flash"| D["V4 Flash<br/>fast + cheapest"]
  C -->|"deepseek-v4-pro"| E["V4 Pro<br/>higher quality"]
  D --> F{"thinking<br/>enabled?"}
  E --> F
  F -->|"no"| G["Response content"]
  F -->|"yes"| H["reasoning_content<br/>plus answer content"]

Official Documentation

Resource URL
API Docs https://api-docs.deepseek.com/
API Reference https://api-docs.deepseek.com/api/create-chat-completion
Models & Pricing https://api-docs.deepseek.com/quick_start/pricing
OpenAI Compatibility https://api-docs.deepseek.com/quick_start/compatibility_guide
Reasoning (thinking mode) https://api-docs.deepseek.com/guides/reasoning_model

SDK Setup

DeepSeek is OpenAI API-compatible — use the official OpenAI SDK with a custom base URL.

npm install openai
import OpenAI from "openai";

const deepseek = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY!,
  baseURL: "https://api.deepseek.com",
});

Models

Model ID Serves Best For Context Thinking mode
deepseek-v4-flash DeepSeek-V4-Flash-0731 Fast chat, code, structured output, high-volume bulk 1M tokens optional (per request)
deepseek-v4-pro DeepSeek-V4-Pro-0813 Higher-quality answers, harder reasoning/math/logic 1M tokens optional (per request)
deepseek-v4-flash-vision-exp experimental multimodal Image input; matches Flash on text 1M tokens optional (per request)

Both deepseek-v4-flash and deepseek-v4-pro support the dual thinking / non-thinking mode — reasoning is a per-request toggle, not a separate model (see Reasoning (R1-style) — thinking mode below). Use exact IDs; DeepSeek rolls new checkpoints (e.g. -0731, -0813) under the stable base ID, so keep using deepseek-v4-flash / deepseek-v4-pro.

Retired: deepseek-chat and deepseek-reasoner (the V3/R1 IDs) were retired after 2026-07-24 — do not use them in new code.


Core Patterns

Standard Chat Completion

const response = await deepseek.chat.completions.create({
  model: "deepseek-v4-flash",
  messages: [
    { role: "system", content: "You are a helpful assistant for codeAmani Labs." },
    { role: "user", content: "Summarize this M-Pesa transaction log." },
  ],
  max_tokens: 1024,
});

console.log(response.choices[0].message.content);

Reasoning (R1-style) — thinking mode

Chain-of-thought is now a per-request thinking toggle on the V4 models — enable it with reasoning_effort plus DeepSeek's thinking extension. When enabled, the model exposes its reasoning in reasoning_content before the final content.

const response = await deepseek.chat.completions.create({
  model: "deepseek-v4-pro",
  messages: [
    { role: "user", content: "Why is my Supabase RLS policy blocking authenticated users?" },
  ],
  reasoning_effort: "high",
  // `thinking` is a DeepSeek extension not in the OpenAI types; the SDK forwards it.
  // @ts-expect-error — deepseek-specific field
  thinking: { type: "enabled" },
  max_tokens: 4096,
});

const choice = response.choices[0];
// @ts-expect-error — deepseek-specific field
console.log("Reasoning:", choice.message.reasoning_content);
console.log("Answer:", choice.message.content);

Do not feed reasoning_content back into message history — it is intermediate scratch-work, not part of the conversation.

Streaming Response

const stream = await deepseek.chat.completions.create({
  model: "deepseek-v4-flash",
  stream: true,
  messages: [{ role: "user", content: prompt }],
});

for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content ?? "";
  process.stdout.write(delta);
}

Next.js App Router Streaming Route

// app/api/ai/deepseek/route.ts
import OpenAI from "openai";
import { NextRequest } from "next/server";

const deepseek = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY!,
  baseURL: "https://api.deepseek.com",
});

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  const stream = await deepseek.chat.completions.create({
    model: "deepseek-v4-flash",
    stream: true,
    messages,
  });

  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        const text = chunk.choices[0]?.delta?.content ?? "";
        if (text) controller.enqueue(encoder.encode(text));
      }
      controller.close();
    },
  });

  return new Response(readable, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

AI Routing: When to Use DeepSeek

In codeAmani's AI routing strategy, DeepSeek slots in as a cost-optimization tier:

This decision flow shows exactly where DeepSeek earns its place alongside Anthropic — pick the right tier and you save cost without losing quality:

flowchart TD
  A["Incoming task"] --> B{"High complexity<br/>or needs reasoning?"}
  B -->|"yes"| C["Anthropic<br/>claude-sonnet-4-6"]
  B -->|"no"| D{"Medium complexity?"}
  D -->|"yes"| E["DeepSeek<br/>deepseek-v4-flash"]
  D -->|"no"| F["Anthropic<br/>claude-haiku-4-5"]
// lib/ai.ts
type TaskComplexity = "high" | "medium" | "low";

function selectModel(complexity: TaskComplexity, requiresReasoning: boolean) {
  if (complexity === "high" || requiresReasoning) {
    return { provider: "anthropic", model: "claude-sonnet-4-6" };
  }
  if (complexity === "medium") {
    return { provider: "deepseek", model: "deepseek-v4-flash" };
  }
  // Low complexity: fast classification / simple Q&A
  return { provider: "anthropic", model: "claude-haiku-4-5-20251001" };
}
Task Recommended Model
Complex reasoning, agents claude-sonnet-4-6
Step-by-step math / logic deepseek-v4-pro (thinking on)
Standard Q&A, summaries deepseek-v4-flash
Fast classification claude-haiku-4-5-20251001
Structured JSON output gpt-4o

Error handling, retries & fallback

The routing section above treats DeepSeek as a cost tier, not a hard dependency — so any call that hits DeepSeek must be able to fall back to Anthropic Claude when DeepSeek throttles or errors. DeepSeek's own docs explicitly suggest this: on a 429, they recommend you "temporarily switch to alternative LLM providers."

Documented status codes

These are the status codes DeepSeek documents on its error codes page (unchanged under V4). Treat the transient ones as retry-then-fallback, and the terminal ones as fail-fast (retrying won't help):

Code Meaning Class Action
400 Invalid request body format terminal Fix the request — do not retry
401 Authentication fails (wrong API key) terminal Fix DEEPSEEK_API_KEY
402 Insufficient balance terminal Top up; fall back immediately
422 Invalid parameters terminal Fix params — do not retry
429 Rate limit reached (concurrency limit) transient Back off, then fall back
500 Server error transient Retry after a brief wait
503 Server overloaded (high traffic) transient Retry after a brief wait

DeepSeek does not publish a fixed requests-per-second limit. Instead it documents a per-user_id concurrency limit (rate limit docs); exceeding the number of in-flight connections is what returns 429. The docs give no prescribed backoff schedule, so the pattern below uses standard exponential backoff with jitter.

Try DeepSeek with backoff, then fall back to Claude

This mirrors the router in lib/ai.ts — selectModel chooses the tier, this wrapper makes the DeepSeek tier resilient. Terminal errors (4xx except 429) skip retries and fall straight through to Claude.

// lib/ai-resilient.ts
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";

const deepseek = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY!,
  baseURL: "https://api.deepseek.com",
});

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

// Transient per DeepSeek docs: 429 (rate limit), 500 (server error), 503 (overloaded).
const RETRYABLE = new Set([429, 500, 503]);
const MAX_RETRIES = 3;

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/**
 * Run a prompt through DeepSeek with exponential backoff on transient errors,
 * then fall back to Anthropic Claude if DeepSeek is unavailable or non-retryable.
 */
export async function completeWithFallback(prompt: string): Promise<string> {
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    try {
      const res = await deepseek.chat.completions.create({
        model: "deepseek-v4-flash",
        messages: [{ role: "user", content: prompt }],
        max_tokens: 2048,
      });
      return res.choices[0]?.message.content ?? "";
    } catch (err) {
      // OpenAI SDK surfaces the HTTP status on err.status
      const status = (err as { status?: number }).status;

      // Non-retryable (400/401/402/422) or out of attempts → break to fallback.
      if (!status || !RETRYABLE.has(status) || attempt === MAX_RETRIES) break;

      // Exponential backoff with full jitter: ~0.5s, 1s, 2s (+ jitter).
      const base = 500 * 2 ** attempt;
      await sleep(base + Math.random() * base);
    }
  }

  // Fallback tier — Anthropic Claude (consistent with lib/ai.ts router).
  const msg = await anthropic.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 2048,
    messages: [{ role: "user", content: prompt }],
  });
  const block = msg.content[0];
  return block.type === "text" ? block.text : "";
}
flowchart TD
  A["completeWithFallback"] --> B["Call DeepSeek<br/>deepseek-v4-flash"]
  B --> C{"Result?"}
  C -->|"success"| D["Return content"]
  C -->|"429 · 500 · 503"| E{"Retries left?"}
  E -->|"yes"| F["Backoff with jitter<br/>then retry"]
  F --> B
  E -->|"no"| G["Fallback to Claude<br/>claude-haiku-4-5"]
  C -->|"400 · 401 · 402 · 422"| G
  G --> D

Gotcha — empty lines are not errors. While a request waits to be scheduled, DeepSeek keeps the TCP connection alive by sending empty lines (non-streaming) or : keep-alive SSE comments (streaming) rather than data. The OpenAI SDK handles these for you, but if you parse the raw HTTP/SSE stream yourself, skip those blank/comment lines — do not treat them as a malformed response or trip your retry logic on them. Connections also close after ~10 minutes if inference never starts, so set a client timeout below that and let the fallback path catch it.


JSON / Structured Output

const response = await deepseek.chat.completions.create({
  model: "deepseek-v4-flash",
  response_format: { type: "json_object" },
  messages: [
    {
      role: "system",
      content: "Respond only with valid JSON.",
    },
    {
      role: "user",
      content: "Extract: name, amount, phone from this SMS: 'Confirmed. Ksh500 sent to 0712345678 on 12/5/26'",
    },
  ],
});

const data = JSON.parse(response.choices[0].message.content ?? "{}");
// { name: null, amount: 500, phone: "0712345678" }

Environment Variables

# Required
DEEPSEEK_API_KEY=sk-...

# No separate base URL needed — set in code: https://api.deepseek.com

Cost Reference

DeepSeek is significantly cheaper than GPT-4o / Claude for many tasks. Rates change frequently — check current pricing at https://api-docs.deepseek.com/quick_start/pricing. Prices below are per 1M tokens, USD, and split into off-peak / peak: peak hours are 01:00–04:00 and 06:00–10:00 UTC, Mon–Fri; every other hour is off-peak (half the peak rate), so most of the day bills at the cheaper column.

Model Input (cache hit) Input (cache miss) Output
deepseek-v4-flash $0.007 / $0.014 $0.22 / $0.44 $0.66 / $1.32
deepseek-v4-pro $0.022 / $0.044 $0.66 / $1.32 $1.98 / $3.96
deepseek-v4-flash-vision-exp $0.007 / $0.014 $0.22 / $0.44 $0.66 / $1.32

Thinking-mode reasoning tokens are billed at the normal output rate. Cache-hit input is ~30× cheaper than cache-miss — DeepSeek caches prompt prefixes automatically (no cache-control header needed). See reference/pricing-snapshot.md.


Troubleshooting

Issue Fix
Authentication fails Verify DEEPSEEK_API_KEY — starts with sk-
model not found Use exact V4 IDs: deepseek-v4-flash or deepseek-v4-pro (the old deepseek-chat / deepseek-reasoner were retired 2026-07-24)
reasoning_content undefined Only populated when thinking mode is enabled (reasoning_effort + thinking: { type: "enabled" })
Streaming stops mid-response Check max_tokens — default is low; increase to 4096+
TypeScript errors on reasoning_content / thinking Use // @ts-expect-error — DeepSeek fields not in OpenAI types

Official docs:

Docker (Windows) — Containers Developer Guide

Technology: docker · Category: tooling · Last reviewed: 2026-08-30

Source: https://tech-stack.codeamanilabs.org/guide/docker

Insight:

On Windows, "Docker" means Docker Desktop running on the WSL 2 backend — the Linux containers you ship to Cloud Run / Render build against a real Linux kernel (the same one WSL2 runs), so dev/prod parity is built in. The single rule that governs your speed mirrors WSL's: keep the project on the Linux filesystem (~/code, not /mnt/c). A bind-mounted build on /mnt/c crosses the OS boundary on every file op and crawls; the same build from ~/code runs at native speed. Enable WSL integration once, run the docker CLI from inside your distro, and you have the exact container toolchain production uses. In 2026 Docker is also an AI-native platform: hardened base images that ship with SLSA L3 provenance, a local model runtime, a containerised MCP catalog, and microVM sandboxes that run Claude Code with no access to your host.

██████╗  ██████╗  ██████╗██╗  ██╗███████╗██████╗
██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗
██║  ██║██║   ██║██║     █████╔╝ █████╗  ██████╔╝
██║  ██║██║   ██║██║     ██╔═██╗ ██╔══╝  ██╔══██╗
██████╔╝╚██████╔╝╚██████╗██║  ██╗███████╗██║  ██║
╚═════╝  ╚═════╝  ╚═════╝╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝

Docker (Windows) — Containers Developer Guide

Focus: Everything a developer needs to run Docker on Windows 11 — install Docker Desktop on the WSL 2 backend, the filesystem rule that decides your build speed, the core CLI, writing a multi-stage Dockerfile, Docker Compose, volumes & bind mounts (incl. the Windows path gotchas), docker init, base-image choice (Hub rate limits vs Docker Hardened Images), scanning with docker scout, Docker's AI stack (Model Runner, MCP Toolkit, Offload), Docker Sandboxes for running coding agents isolated, and driving it all with Claude Code inside WSL. Grounded in docs.docker.com; reviewed 2026-08-30 against Docker Engine 29.7.2 (2026-08-05) / Docker Desktop 4.88.1 (2026-08-25) / Compose v5.5.0 (2026-08-17).

Containers solve "works on my machine" by shipping the app and its environment as one immutable image. On Windows the whole thing rides on WSL 2 — Docker Desktop runs the Linux engine inside the same lightweight VM WSL uses, so the images you build locally are byte-for-byte the Linux images you deploy. Get the install + the one filesystem rule right and you have production parity on your laptop. Let's dive in.

Table of Contents

  1. Overview & architecture
  2. Install Docker Desktop on Windows
  3. The WSL 2 backend & the filesystem rule
  4. Core CLI quickstart
  5. Images & the Dockerfile
  6. Docker Compose
  7. Volumes & bind mounts (Windows gotchas)
  8. Networking & ports
  9. docker init — scaffold in one command
  10. Base images: Hub limits & Docker Hardened Images
  11. Scan images for CVEs — docker scout
  12. Docker's AI stack — Model Runner, MCP Toolkit, Offload
  13. Docker Sandboxes — run coding agents isolated
  14. Claude Code + Docker
  15. Troubleshooting
  16. codeAmani notes

The interactive learn module above this page is a live container-vs-image + Dockerfile-layer explainer — start there for intuition, then use this reference.


Official Documentation

Resource URL
Install Docker Desktop on Windows https://docs.docker.com/desktop/setup/install/windows-install/
Docker Desktop WSL 2 backend https://docs.docker.com/desktop/features/wsl/
Get started / workshop https://docs.docker.com/get-started/
docker CLI reference https://docs.docker.com/reference/cli/docker/
Compose (overview + file ref) https://docs.docker.com/compose/
Dockerfile reference https://docs.docker.com/reference/dockerfile/
Build best practices https://docs.docker.com/build/building/best-practices/
BuildKit (the default builder) https://docs.docker.com/build/buildkit/
Docker Scout (image CVE scanning) https://docs.docker.com/scout/
Docker Hardened Images (DHI) https://docs.docker.com/dhi/
Docker Model Runner https://docs.docker.com/ai/model-runner/
MCP Catalog & Toolkit https://docs.docker.com/ai/mcp-catalog-and-toolkit/
Docker Sandboxes (sbx) https://docs.docker.com/ai/sandboxes/
Docker Hub usage & rate limits https://docs.docker.com/docker-hub/usage/

1. Overview & architecture

Two words decide everything: image and container.

On Windows, the Docker engine (the daemon that builds images and runs containers) does not run on Windows directly — it runs inside the WSL 2 Linux VM. Docker Desktop is the control plane (GUI, settings, the docker CLI shim) that talks to that engine. Here's the whole stack:

flowchart TB
  subgraph WIN["Windows host"]
    DD["Docker Desktop · GUI + settings"]
    CLI["docker CLI (PowerShell / WSL)"]
  end
  subgraph VM["WSL 2 · lightweight utility VM · real Linux kernel"]
    ENG["dockerd — the engine"]
    subgraph CTRS["Containers"]
      C1["web :3000"]
      C2["postgres :5432"]
      C3["redis :6379"]
    end
    ENG --> C1
    ENG --> C2
    ENG --> C3
  end
  DD -->|manages| ENG
  CLI -->|API| ENG

Why this matters: the containers run on a genuine Linux kernel — the same kernel family as your production hosts (Cloud Run, Render, a Linux VM). There is no translation layer faking Linux; an image that runs here runs there. That's the dev/prod parity payoff.


2. Install Docker Desktop on Windows

System requirements (WSL 2 backend):

Install — download Docker Desktop Installer.exe from docs.docker.com/desktop/setup/install/windows-install/, then either double-click it or run from a terminal:

# All-users install (run the terminal as Administrator)
Start-Process -Wait -FilePath ".\Docker Desktop Installer.exe" -ArgumentList "install"

# Per-user install (no admin) — installs only for the current user
Start-Process -Wait -FilePath ".\Docker Desktop Installer.exe" -ArgumentList "install","--user"

The installer enables the WSL 2 feature for you if it's missing. After install, launch Docker Desktop once and accept the service agreement. The whale icon in the system tray = engine running.

Turn on the WSL 2 engine + per-distro integration (usually on by default):

Docker Desktop → Settings
  → General   → ✅ Use WSL 2 based engine
  → Resources → WSL integration → ✅ Enable integration with my default WSL distro
                                 → ✅ <your distro, e.g. Ubuntu>

Then, inside your WSL distro, confirm the CLI is wired up:

docker version          # client + server (engine) both report
docker run --rm hello-world

If a distro is still on WSL 1, convert it: wsl --set-version <distro> 2.

Keep it current. Docker Desktop is on a fast cadence (4.88.1, 2026-08-25 at the time of review) and Engine patches carry real CVE fixes — 29.7.0 shipped a fix for CVE-2026-17106, and 29.6.x cleared a set of BuildKit findings including a command-injection issue in git checkout. Update from the GUI, or:

docker desktop update check
docker desktop update install

3. The WSL 2 backend & the filesystem rule

Docker on Windows inherits WSL's #1 performance rule — for the same reason (the OS boundary). See the WSL guide for the full story.

flowchart LR
  LFS["Linux fs: ~/code  · FAST"]
  MNT["/mnt/c: Windows C: · slow across boundary"]
  B["docker build / bind mount"]
  B -->|"from ~/code"| LFS
  B -->|"from /mnt/c — 2–20× slower I/O"| MNT

✅ Keep your repo in the Linux filesystem (/home/you/code/...), not /mnt/c. A docker build or a bind-mounted dev server reads thousands of small files; on /mnt/c every read crosses the Windows↔Linux boundary and the build crawls. From ~/code it runs at native speed.

# Right: clone into the Linux fs, build from there
mkdir -p ~/code && cd ~/code
git clone https://github.com/codeamani-solutions/your-repo.git
cd your-repo
docker build -t your-repo .       # fast — files are local to the engine

Bonus: WSL 2 lets multiple distros share one Docker engine, and Docker Desktop manages the VM's resources for you (caps live in %UserProfile%\.wslconfig, e.g. [wsl2] memory=8GB).


4. Core CLI quickstart

The verbs you'll use every day. Run them from inside WSL (or PowerShell — both reach the same engine):

# Images
docker pull node:22-alpine          # fetch an image from Docker Hub
docker images                       # list local images
docker build -t myapp:dev .         # build an image from ./Dockerfile, tag it

# Containers
docker run -d --name web -p 3000:3000 myapp:dev   # run detached, publish a port
docker ps                           # running containers  (-a = include stopped)
docker logs -f web                  # tail a container's logs
docker exec -it web sh              # shell into a running container
docker stop web && docker rm web    # stop + remove

# Housekeeping
docker system df                    # disk used by images/containers/volumes
docker system prune -f              # reclaim space (dangling images, stopped ctrs)
docker system prune -af --volumes   # aggressive: also unused images + volumes
Command Does
docker run [-d] [-p host:ctr] [-e K=V] IMG Create + start a container
docker ps [-a] List running (or all) containers
docker build -t name:tag . Build an image from the Dockerfile in .
docker exec -it <ctr> sh Open a shell inside a running container
docker logs -f <ctr> Stream logs
docker compose up -d Bring up the whole stack (see §6)
docker pull/push <ref> Pull from / push to a registry (Docker Hub)
docker system prune Reclaim disk from unused objects

5. Images & the Dockerfile

A Dockerfile is the recipe. The big lever for small, fast, secure images is multi-stage builds: compile in a fat stage, copy only the artifacts into a lean final stage. Here's a production-grade Next.js example:

# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
WORKDIR /app

# deps — install once, cache by lockfile
FROM base AS deps
COPY package*.json ./
RUN npm ci

# dev — hot-reload target used by Compose in development
FROM base AS dev
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# build — produce the production bundle
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# runner — lean, non-root, only the built output
FROM base AS runner
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
EXPOSE 3000
# HEALTHCHECK lets the engine (and Compose depends_on: condition) know the app is live.
# busybox wget ships in -alpine; no extra package needed.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/ || exit 1
USER node
CMD ["npm", "start"]

BuildKit is the default builder for Docker Desktop and Docker Engine — the # syntax=docker/dockerfile:1 line opts into its latest frontend, enabling parallel stages, cache mounts (RUN --mount=type=cache), and build secrets (--mount=type=secret, §12). docker buildx is the extended build CLI on top of BuildKit for multi-platform (--platform linux/amd64,linux/arm64) and named builders. (Only Windows containers fall back to the legacy builder — not relevant here, since the WSL 2 backend builds Linux images.)

Always pair it with a .dockerignore so junk never enters the build context (faster builds, smaller images, fewer secret leaks):

node_modules
.git
.next
npm-debug.log
.env*
Dockerfile
.dockerignore

Layer-caching rule of thumb: order from least- to most-frequently-changed. Copy package*.json and npm ci before COPY . ., so editing source code doesn't bust the dependency layer.

flowchart LR
  A["FROM node:22-alpine"] --> B["COPY package*.json"]
  B --> C["RUN npm ci  ← cached unless lockfile changes"]
  C --> D["COPY . .  ← busts on any source edit"]
  D --> E["RUN npm run build"]

BuildKit flags worth knowing

The # syntax=docker/dockerfile:1 line pins the latest stable frontend, so these are available without extra config:

Flag Since What it buys you
RUN --mount=type=cache,target=... v1.2 Persist a package-manager cache across builds — npm/pip/apt stop re-downloading
RUN --mount=type=secret,id=... v1.2 Read a secret during build without baking it into a layer
RUN --mount=type=bind,from=... v1.2 Read files from another stage/context without a COPY layer
RUN --mount=type=ssh v1.2 Use the host SSH agent for private-repo git clone
ADD --checksum=sha256:... v1.6 Verify a remote download — pin it or don't trust it
COPY --exclude=... v1.19 Skip paths inside a COPY without touching .dockerignore
COPY --parents v1.20 Preserve the source directory structure when copying globs
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
# Cache mount: node_modules downloads survive between builds; the cache is NOT a layer.
RUN --mount=type=cache,target=/root/.npm npm ci

FROM deps AS build
COPY . .
# Build secret: available at /run/secrets/npm_token for THIS instruction only.
# Nothing is written to the image, so nothing leaks when the image is pushed.
RUN --mount=type=secret,id=npm_token     NPM_TOKEN=$(cat /run/secrets/npm_token) npm run build
# Pass the secret from a file or an env var — never as a build ARG.
docker build --secret id=npm_token,src=./npm_token.txt -t myapp:dev .
docker build --secret id=npm_token,env=NPM_TOKEN     -t myapp:dev .

ARG and ENV are not secret. Both are recorded in the image's build history — docker history prints them back. A token passed as --build-arg is a published token. Use --mount=type=secret for build-time credentials, and --env-file (§7) for run-time ones.


6. Docker Compose

Compose declares a multi-container stack in one compose.yaml and brings it up with a single command — perfect for "app + Postgres + Redis" local dev. The target: line ties a service to a Dockerfile stage (§5):

No top-level version: key. It's obsolete — the Compose spec treats it as informational only and warns if you use it (docker compose always validates against the latest schema). Start the file at services:. docker init and the examples here already omit it; don't add it back.

# compose.yaml   ← no `version:` key (obsolete)
services:
  web:
    build:
      context: .
      target: dev          # use the hot-reload stage from the Dockerfile
    ports:
      - "3000:3000"
    env_file:
      - .env.local         # never committed — see codeAmani notes
    depends_on:
      db:
        condition: service_healthy   # wait for Postgres to pass its healthcheck
    pre_start:             # init containers — run to completion BEFORE web starts
      - command: ["npm", "run", "db:migrate"]
    develop:
      watch:               # rebuild/sync on file changes
        - action: sync
          path: .
          target: /app
          initial_sync: true         # seed the container before watching
          ignore:
            - node_modules/
        - action: rebuild
          path: package.json

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - dbdata:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:5432:5432"        # loopback only — keep the dev DB off the LAN
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  dbdata:
docker compose up -d          # build + start the stack in the background
docker compose watch          # live-sync/rebuild as you edit (modern dev loop)
docker compose logs -f web    # tail one service
docker compose ps             # what's running
docker compose down           # stop + remove containers + network
docker compose down -v        # ...and delete named volumes (wipes the DB)

Inside the Compose network, services reach each other by service name — the web app connects to Postgres at db:5432, not localhost. localhost inside a container is the container itself.

docker compose (space), not docker-compose (hyphen). Compose is now a Docker CLI plugin (the docker compose subcommand, currently v5.5.0, bundled with Docker Desktop). The old standalone Python docker-compose v1 reached end of life in 2024 and was removed in 2025 — if a script still calls the hyphenated form it's running unmaintained software. Convert docker-compose … → docker compose ….

Init containers: pre_start

pre_start runs one or more ephemeral containers to completion before the service's own container starts — and only after its depends_on conditions are satisfied. That is exactly the shape of "migrate the database, then boot the app", which previously needed an entrypoint wrapper or a hand-rolled wait-for-it script:

services:
  web:
    build: .
    depends_on:
      db:
        condition: service_healthy
    pre_start:
      - command: ["npm", "run", "db:migrate"]   # runs in the service's own image
      - image: busybox                          # ...or a different one
        command: sh -c 'chown -R 1000:1000 /data'
    volumes:
      - data:/data

There is a matching post_start (and pre_stop), but those run inside the already-running container, not as separate ephemeral ones — use pre_start for anything that must finish before the app accepts traffic.

Ordering trap: depends_on: condition: service_started only waits for the container to exist. A Postgres container exists long before it accepts connections. Use condition: service_healthy with a real healthcheck: (as in the file above) or your migration step races the database on a cold start.


7. Volumes & bind mounts (Windows gotchas)

Containers are ephemeral — their writable layer dies with them. Two ways to persist or share data:

Type Syntax Use for
Named volume --mount type=volume,src=dbdata,target=/var/lib/postgresql/data Databases, anything the engine should own
Bind mount --mount type=bind,src="$(pwd)",target=/app Live-editing source during dev
# Dev loop: bind-mount the source so edits reflect instantly
docker run -dp 127.0.0.1:3000:3000 \
  -w /app --mount type=bind,src="$(pwd)",target=/app \
  node:22-alpine sh -c "npm install && npm run dev"

Windows-specific gotchas:


8. Networking & ports

-p host:container publishes a container port to the host. With Docker Desktop's WSL 2 backend, published ports are reachable at localhost from both Windows and WSL — so a container on -p 3000:3000 opens in your Windows browser at http://localhost:3000.

docker run -d -p 8080:80 nginx            # nginx :80 → http://localhost:8080
docker run -d -p 127.0.0.1:5432:5432 postgres:17   # bind to loopback only (safer)

Bind to 127.0.0.1 for anything with data. -p 5432:5432 listens on all interfaces; -p 127.0.0.1:5432:5432 keeps your dev Postgres off the LAN. Compose services talk over their private network by name (db:5432) and only need a published port when you (the host) connect.


9. docker init — scaffold in one command

Don't hand-write the first Dockerfile. docker init detects your stack (Node, Python, Go, Rust, PHP, …) and generates a sensible Dockerfile, compose.yaml, .dockerignore, and README.Docker.md:

cd ~/code/your-repo
docker init            # answers a few prompts, writes the four files
docker compose up      # run what it scaffolded

Supported platforms: ASP.NET Core, Go, Java (Maven/uber-jar), Node, PHP with Apache, Python, Rust, plus an Other general-purpose template.

It's the fastest way to a working baseline; then tune the multi-stage Dockerfile (§5) and Compose file (§6) to taste.


10. Base images: Hub limits & Docker Hardened Images

Your base image decides two things you feel later: how many CVEs you inherit on day one, and whether CI can even pull it.

Docker Hub pull rate limits

Pulls are metered, and the anonymous tier is small enough that one busy CI runner blows through it:

Who is pulling Limit (per 6 hours)
Unauthenticated 100 — per IPv4 address or IPv6 /64 subnet
Authenticated personal account (free) 200
Pro / Team / Business Unlimited

The trap is the shared address: every anonymous pull from one cloud CI runner or one office NAT draws on the same 100. A 429 Too Many Requests in the middle of a build is almost always this, not a Docker outage. Authenticate in CI and the problem disappears:

echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USER" --password-stdin

Docker itself needs no application credentials — the only variables are the optional registry logins, set as CI secrets (GitHub Actions / Vercel), never committed:

DOCKERHUB_USER=your-docker-id
DOCKERHUB_TOKEN=dckr_pat_...        # a read-only access token, NOT your password

Use a scoped access token, not your account password: tokens are revocable individually and can be read-only, which is all a CI pull needs.

Docker Hardened Images (dhi.io)

Docker Hardened Images are minimal, production-ready images maintained by Docker and published to their own registry, dhi.io. The catalog is free for community use under Apache 2.0; paid tiers add SLA-backed patching and FIPS/STIG/ELS variants. What you get per image:

That last line is why this matters to us specifically: codeAmani's supply-chain policy already targets SLSA Build L3 for anything we ship (see CLAUDE.md). Starting from a base that already carries L3 provenance means the only provenance you have to generate is your own layer.

docker login dhi.io                 # a free Docker account is enough
docker pull dhi.io/node:24-debian13
docker pull dhi.io/python:3.13
docker run --rm dhi.io/python:3.13 python -c "print('hello from DHI')"

Docker's own before/after on the Python image: 91% smaller (35 MB vs 412 MB) and 87% fewer packages (80 vs 610), clearing 1 high / 5 medium / 141 low findings.

The catch — and it is the whole point. Hardened images deliberately omit tooling you may expect. On a distroless runtime variant there is no shell, so docker exec -it <ctr> sh fails and a RUN step that shells out breaks. That is the attack surface being gone, not a bug. Build in the -dev variant, ship the runtime one:

# syntax=docker/dockerfile:1
FROM dhi.io/node:24-debian13-dev AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM dhi.io/node:24-debian13 AS runner    # runtime variant: no shell, no npm
WORKDIR /app
COPY --from=build /app/.next ./.next
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/public ./public
EXPOSE 3000
USER nonroot
CMD ["node_modules/.bin/next", "start"]

Note the CMD is an exec-form call to a real binary. CMD ["npm", "start"] would need a shell in some images; on a distroless base, call the binary directly.


11. Scan images for CVEs — docker scout

Before an image ships, scan it. Docker Scout builds an SBOM (software bill of materials) from your image's layers and matches every package against a continuously updated vulnerability database — so you catch a known-vulnerable base image or transitive dependency before it's deployed, not after.

docker build -t myapp:dev .
docker scout quickview myapp:dev     # one-line summary: how many CVEs, by severity
docker scout cves myapp:dev          # the full list — package, CVE id, fixed-in version
docker scout recommendations myapp:dev   # suggested base-image bumps that clear CVEs

quickview is the fast gate; cves is the detail when it flags something; recommendations often points at a newer -alpine/-slim base tag that clears the finding. Scout is built into Docker Desktop and the CLI — no separate install.

Beyond the three above, the subcommands you actually reach for:

Command Does
docker scout quickview <img> One-line severity summary — the fast CI gate
docker scout cves <img> Full finding list: package, CVE id, fixed-in version
docker scout recommendations <img> Base-image bumps that clear findings
docker scout sbom <img> Generate/print the image's SBOM
docker scout compare <a> --to <b> Diff two images/tags (experimental)
docker scout vex / attestation Manage VEX statements and in-toto attestations
docker scout policy <img> Evaluate local Rego policies against an image (experimental)

Scanning ≠ provenance. Scout tells you what's inside an image and whether it's vulnerable; SLSA provenance (see codeAmani notes) proves how and where the image was built. Shipped container images want both — they complement each other, neither replaces the other.


12. Docker's AI stack — Model Runner, MCP Toolkit, Offload

Three products that turn Docker from "the thing that runs my Postgres" into part of the AI toolchain. All three ship with Docker Desktop.

Docker Model Runner — local models, OpenAI-compatible

docker model pulls models from Docker Hub or Hugging Face as OCI artifacts and serves them behind an OpenAI- and Ollama-compatible API. The mental model is docker run, but the thing you run is a model:

docker model pull    ai/qwen2.5-coder        # from Docker Hub or Hugging Face
docker model run     ai/qwen2.5-coder "Summarise this changelog"
docker model list                            # what's pulled locally
docker model ps                              # what's running
docker model status                          # is the runner up?
docker model configure --context-size 8192 ai/qwen2.5-coder
docker model df                              # disk used by models
docker model unload                          # free the VRAM

Because the endpoint is OpenAI-shaped, an existing openai client points at it by changing only the base URL — no separate SDK.

Compose integration. A model can be a declared dependency of your stack, so docker compose up starts the model alongside the app. The top-level models: element declares it; the service references it and Compose injects the endpoint:

services:
  app:
    build: .
    models:
      my_model:
        endpoint_var: MODEL_URL      # Compose sets MODEL_URL in the container

models:
  my_model:
    model: ai/gemma3:4B-Q4_0
    context_size: 10000
    runtime_flags:
      - "--a-flag"

With the short syntax (models: [my_model]) Compose injects a derived variable instead; the long syntax above lets you name it.

Windows GPU requirements. On AMD64 you need an NVIDIA GPU with driver 576.57 or later. On ARM64 it runs via OpenCL on Qualcomm Adreno (6xx series and later), where some llama.cpp features may not be fully supported. Without a supported GPU, expect CPU inference speeds.

Docker MCP Catalog & Toolkit — MCP servers as containers

The MCP Catalog is a curated set of 300+ verified MCP servers packaged as container images; the MCP Toolkit (a Docker Desktop tab) runs them and exposes them to MCP clients through a gateway. The payoff is that an MCP server's dependencies live in a container instead of on your machine, and you configure the set once rather than per client.

Three concepts: Catalogs (what is available), Profiles (named groups of servers, e.g. web-dev), and Clients (Claude Code, Claude Desktop, VS Code, Cursor...) that connect through the gateway.

# Connect a client to a profile's servers
docker mcp client connect vscode --profile my_profile

For Claude Desktop, Docker Desktop's MCP Toolkit -> Clients tab has a one-click Connect; restart the client afterwards. Note the enterprise MCP Gateway under Docker AI Governance is an invite-only feature — the Toolkit itself is not.

This is an alternative delivery mechanism for MCP servers, not a replacement for our own wiring. codeAmani's servers are configured directly in MASTER_MCP_CONFIG.md; reach for the Toolkit when you want a third-party server without installing its runtime on the host.

Docker Offload — borrow a bigger machine

Docker Offload is a managed service that runs builds and containers in Docker's cloud using the same CLI you already use, then streams results back. It exists for the cases where local hardware is the blocker: a machine that cannot nest virtualization, a VDI environment, or a build that wants more cores than you own. Availability depends on your Docker subscription — check the Offload docs before designing around it.


13. Docker Sandboxes — run coding agents isolated

Docker Sandboxes run an AI coding agent inside a microVM: its own kernel, its own filesystem, its own network stack, and its own private Docker daemon. The agent can install packages, rewrite configs, and start containers, and your host is untouched. Claude Code is a natively supported agent.

The CLI is sbx — note it is not docker sandbox, and it needs neither Docker Desktop nor Docker Engine installed.

# Windows 11 — install and authenticate
winget install -h Docker.sbx
sbx login
sbx run claude      # launch Claude Code inside a fresh sandbox

Windows gotcha — this one does not ride on WSL 2. Unlike everything else in this guide, sbx needs Windows 11 with the Windows Hypervisor Platform feature enabled (it runs its own microVM). If sbx cannot start a sandbox on a machine where Docker Desktop works fine, this is why — enable the feature and reboot. macOS needs Sonoma 14+ on Apple silicon; Linux needs Ubuntu 24.04+ with KVM.

The sbx CLI is free to use, including for commercial work. Network access is governed by configurable allow/deny lists, which is the point: an agent running unsupervised should not be able to reach arbitrary hosts.

When it earns its keep: letting an agent run a risky migration, a dependency upgrade, or an untrusted build without staking your host on it. Compare with the WSL Ubuntu sandbox, which gives a disposable distro — cheaper and already on your machine, but sharing the host kernel and your Docker daemon. sbx is the stronger boundary; the WSL sandbox is the lighter one.


14. Claude Code + Docker

Docker pairs naturally with running Claude Code inside WSL — same Linux toolchain, same engine.

# Inside WSL, in your repo on the Linux fs
claude
# Then, in the session:
#   "Add a multi-stage Dockerfile + compose.yaml for this Next.js app"
#   "Why is my docker build slow?"  → it'll spot a /mnt/c repo or a missing .dockerignore
#   "docker compose up and verify the app serves on :3000"

Why it clicks:

Let Claude run builds in containers when a task needs a clean room, but keep the repo on ~/code (the §3 rule) so the build context is fast.

Three levels of isolation, cheapest first — pick by how much you trust the task:

Level What it is Blast radius
docker run --rm a throwaway container One process, shared kernel + your daemon The container
WSL Ubuntu sandbox A disposable distro you can export/import That distro
sbx run claude (§13) microVM: own kernel, own Docker daemon, network policy The sandbox

Unsupervised or untrusted work belongs at the bottom row. Routine "build this and run the tests" is fine at the top.


15. Troubleshooting

Symptom Fix
docker: command not found in WSL Settings → Resources → WSL integration → enable your distro; reopen the shell
Engine won't start / "Docker Desktop stopped" Confirm virtualization is on in BIOS; wsl --update; restart Docker Desktop
docker build is painfully slow Repo is on /mnt/c — move it to ~/code; add a .dockerignore
Bind mount empty / not updating (Git Bash) Use -w //app (double slash) or run from WSL/PowerShell; for hot-reload use compose watch
Port already allocated Another process owns it — change the host port (-p 3001:3000) or stop the other container
Container can't reach another service Use the service name (db:5432), not localhost, inside the Compose network
the attribute version is obsolete on compose up Delete the top-level version: key from compose.yaml — it's informational only now
docker-compose: command not found Use docker compose (space) — the v1 hyphenated binary is EOL/removed
429 Too Many Requests pulling a base image Docker Hub anonymous limit (100/6h per IP, shared across a NAT/CI runner) — docker login in CI (§10)
docker exec -it <ctr> sh fails: exec: "sh": not found A distroless/hardened image has no shell by design (§10) — debug via the -dev variant or docker logs
docker model run is very slow on Windows No supported GPU — AMD64 needs NVIDIA driver 576.57+; otherwise it is CPU inference (§12)
sbx won't start a sandbox (Docker Desktop is fine) sbx uses its own microVM, not WSL 2 — enable Windows Hypervisor Platform and reboot (§13)
Migration races the database on compose up depends_on: service_started only waits for existence — use service_healthy + pre_start (§6)
Secret visible in docker history It was a --build-arg/ENV — use --mount=type=secret instead (§5)
Disk filling up docker system df then docker system prune -af --volumes (deletes unused volumes!)
WSL VM eating RAM Cap it: %UserProfile%\.wslconfig → [wsl2] memory=8GB, then wsl --shutdown
Image huge Use a multi-stage build + -alpine/-slim base; copy only build output into the runner stage

16. codeAmani notes

Official docs:

Domain Portfolio Integration Guide

Technology: domain-portfolio · Category: domains · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/domain-portfolio

Insight:

This is the strategy layer above Porkbun, not the registrar itself — Porkbun holds the names, this guide says which to build, in what order, and how each monetizes. Every entry is one of the same five reusable plays (umbrella+subdomain SEO, directory lead-gen, course/cert, marketplace, defensive hold). Check the renewal-pricing flag before committing any non-.org TLD.

██████╗  ██████╗ ███╗   ███╗ █████╗ ██╗███╗   ██╗
██╔══██╗██╔═══██╗████╗ ████║██╔══██╗██║████╗  ██║
██║  ██║██║   ██║██╔████╔██║███████║██║██╔██╗ ██║
██║  ██║██║   ██║██║╚██╔╝██║██╔══██║██║██║╚██╗██║
██████╔╝╚██████╔╝██║ ╚═╝ ██║██║  ██║██║██║ ╚████║
╚═════╝  ╚═════╝ ╚═╝     ╚═╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝

██████╗  ██████╗ ██████╗ ████████╗███████╗ ██████╗ ██╗     ██╗ ██████╗
██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝██╔═══██╗██║     ██║██╔═══██╗
██████╔╝██║   ██║██████╔╝   ██║   █████╗  ██║   ██║██║     ██║██║   ██║
██╔═══╝ ██║   ██║██╔══██╗   ██║   ██╔══╝  ██║   ██║██║     ██║██║   ██║
██║     ╚██████╔╝██║  ██║   ██║   ██║     ╚██████╔╝███████╗██║╚██████╔╝
╚═╝      ╚═════╝ ╚═╝  ╚═╝   ╚═╝   ╚═╝      ╚═════╝ ╚══════╝╚═╝ ╚═════╝

Domain Portfolio Integration Guide

Focus: Turning codeAmani's owned-domain catalog into build decisions — which name to develop first, how each one earns, and the reusable patterns that repeat across clusters. This is the strategy layer; porkbun is the registrar/DNS layer that holds and wires the names.

Overview

codeAmani Labs holds ~40 strategic domains under Porkbun. Left as a flat list, that's just a renewal bill. This guide is the source of truth for build prioritization: every domain carries a priority tag, sits inside a thematic cluster, and maps to one of a handful of repeatable monetization plays. Use it to answer "should I build X, redirect it, or just hold it?" without re-deriving the strategy each time.

The portfolio is not a tech in the SDK sense — there is no package to install. It's a decision asset. The companion technical capability lives in the Porkbun guide and the porkbun-dns skill, which is what actually points a built property's DNS at Vercel.

Official Documentation

Resource URL
Porkbun control panel (where the names live) https://porkbun.com/account/domainsSpeedy
Porkbun API (programmatic DNS / availability) https://porkbun.com/api/json/v3/documentation
Source file C:\Users\info\Downloads\domain-portfolio (2).md (revision 2, compiled 2026-05-29)

Priority tags — the decision key

This is the heart of the guide — once you can read the tag, every name tells you what to do next. Here is that decision in one glance:

flowchart TD
  A["Owned domain"] --> Q1{"Which priority tag?"}
  Q1 -->|"Flagship"| B["Build first"]
  Q1 -->|"High"| C["Build soon"]
  Q1 -->|"Medium"| D["Build or park on capacity"]
  Q1 -->|"Defensive"| E["Hold - do not build separately"]
  Q1 -->|"Standalone"| F["Run as separate property"]
  B --> G{"Renewal pricing OK?"}
  C --> G
  D --> G
  G -->|"Yes"| H["Commit and build"]
  G -->|"No"| I["Park or drop"]

Every name carries exactly one tag. The tag is the instruction:

Tag Meaning Action
Flagship Highest confidence and fit Build first
High Strong revenue or strategic value Build soon
Medium Good value Build or park based on capacity
Defensive Blocks a competitor or preserves a redirect Hold — do not build separately
Standalone Legitimate but outside the core professional brand Run as a separate property

Renewal caution: most names are .org at the $12 floor (Porkbun renewal $11.84, verified 2026-08-23). The genuinely elevated TLDs — .tech ($51), .church ($47), .academy ($38), .courses ($31), and above all .travel ($119/yr) — renew well above that; .place is only a mild step up (~$18/yr, barely above .org). Verify renewal pricing before committing; a "High" name on a $100+ TLD is only High if it's built inside its value window.

  1. Florida health umbrella — verifiedflproviders.org + agency subdomains + apdprovidertraining.org
  2. floridahome.place and floridahub.place — already committed
  3. One lead-gen validator — floridacarestaffing.org or flcarinsurancelistings.org, to prove the directory engine
  4. Expand into the remaining clusters

Scoring a property — a worksheet

The priority tags above answer roughly what to do, but "which to build first" still rests on qualitative judgment. When two names both read "High", you need comparable per-property numbers to break the tie. This worksheet turns the tag into a score you compute yourself — one row per domain, same columns every time, so two names sit side by side.

This is a template, not a dataset. The rows below are illustrative placeholders to show the shape — they are not claimed market figures. Fill the real values per domain from your own renewal bill (Porkbun), keyword research, and capacity estimate. Do not treat the example numbers as guidance.

The columns

Column What you enter Scale
Domain The name —
Priority tag From the decision key above Flagship / High / Medium / Defensive / Standalone
Renewal cost (KES/yr) Actual renewal price from Porkbun, in KES number — lower is better
Search intent How commercially hungry the queries are 1 = browse · 3 = research · 5 = ready-to-transact
Monetization path Which of the five plays applies, and how directly it earns 1 = vague · 5 = clear paying customer
Build effort Your capacity cost to ship a real v1 1 = weekend · 5 = multi-month
Priority tag weight Numeric form of the tag, for the formula Flagship 5 · High 4 · Medium 3 · Standalone 2 · Defensive 1

The weighted-score formula

Compute one number per row so the list sorts itself. A worked starting weighting (tune to taste):

score = (intent × 3) + (monetization × 3) + (tag_weight × 2) − (build_effort × 2)

Renewal cost stays out of the additive score on purpose — treat it as a gate, not a slider: if the renewal is above your TLD ceiling and the name isn't already built inside its value window, the row is parked regardless of score (this mirrors the "Renewal pricing OK?" diamond in the decision flow). Higher score = build sooner. The two negative build_effort points keep a tempting-but-expensive build from outranking a fast one with equal earnings.

Example rows — illustrative placeholders, not market figures

Domain Tag Renewal (KES/yr) Intent (1-5) Monetization (1-5) Build effort (1-5) Tag wt Score
example-flagship.org (example) Flagship ~1300 (example) 5 5 3 5 (5×3)+(5×3)+(5×2)−(3×2) = 34
example-highvalue.org (example) High ~1300 (example) 4 4 2 4 (4×3)+(4×3)+(4×2)−(2×2) = 28
example-premium.travel (example) High ~15000 (example) 5 4 4 4 score high, but renewal gate may park it
example-defensive.org (example) Defensive ~1300 (example) 2 1 1 1 (2×3)+(1×3)+(1×2)−(1×2) = 9 — hold, do not build

Read the table top-down: the flagship outscores the high-value name on intent and tag weight; the premium .travel row scores well but trips the renewal gate; the defensive name floors out, confirming it stays a hold. The arithmetic just makes explicit what the tags imply — and surfaces ties the tags alone can't.

The scoring decision in one glance

flowchart TD
  A["Domain to score"] --> B["Fill the row<br/>intent · monetization · effort · tag"]
  B --> C["Compute score<br/>see formula"]
  C --> D{"Renewal under<br/>TLD ceiling?"}
  D -->|"No · not in value window"| E["Park or drop"]
  D -->|"Yes"| F{"Score vs other rows?"}
  F -->|"Highest"| G["Build first"]
  F -->|"Mid"| H["Build soon or on capacity"]
  F -->|"Low · defensive"| I["Hold - do not build"]

Gotcha — renewal creep on premium TLDs. A row's renewal column is not a one-time number. Premium TLDs (.travel, .tech, .church, .academy, .courses) frequently raise renewal pricing year over year, and the first-year promo price is often far below the renewal you'll actually pay (.courses registers near $1.50 but renews ~$31; .tech registers ~$7 but renews ~$51). Score against the renewal figure, not the registration teaser — and re-enter the renewal column at each annual review, because a name that passed the gate last year can fail it this year without you touching the build. Cheap .org rows (and, on current Porkbun pricing, .place at ~$18) are effectively immune to this; premium-TLD rows need the number refreshed every cycle.


The five reusable plays

Every domain in the portfolio is an instance of one of these. Learn the play once, apply it across clusters:

Here is the signature umbrella play as a reusable build flow — master this shape and the rest follow naturally:

flowchart LR
  A["Umbrella directory<br/>source of truth"] --> B["Vertical subdomains<br/>apd. dcf. ahca."]
  A --> C["Exact-match landers<br/>apdproviders.org"]
  C -->|"301 redirect"| A
  B --> D["High-intent search capture"]
  C --> D
  D --> E["Lead-gen and verified tiers earn"]
  1. Umbrella + subdomain + exact-match SEO — one source-of-truth directory (verifiedflproviders.org) runs verticals as subdomains (apd., dcf., ahca.) and absorbs exact-match landers (apdproviders.org) via 301 redirect for high-intent search capture.
  2. Directory lead-gen engine — a niche directory monetized through provider lead-gen + featured/verified tiers. Reused from the landscaping model across cleaning, mechanics, business, insurance.
  3. Course / certification info-product — productized expertise with a real backed assessment so the credential is defensible (apdprovidertraining.org, promptmastery.academy, workfromhomecertification.org).
  4. Two-sided marketplace — connects supply and demand, charges both sides (floridacarestaffing.org: providers pay for placements, caregivers pay for premium profiles).
  5. Defensive hold / redirect — owns a variant to block competitors or feed SEO into the primary; never built standalone (supportcoordinators.org, aipromptmastery.academy).

Clusters at a glance

1. Florida Health & Care — crown jewel

The most defensible cluster, backed by real expertise (Pathway Licensing, T&T Serenity Care). verifiedflproviders.org is the umbrella; apd./dcf./ahca. run as subdomains; apdproviders.org/dcfproviders.org/ahcaproviders.org are exact-match landers that redirect in. Training arm: apdprovidertraining.org. Marketplace: floridacarestaffing.org. Premium directory: floridaprivatecare.org. Sleeper distribution channel: waiversupportcoordinators.org (WSCs hold the client relationship).

2. Florida Anchor Platforms — committed builds

floridahome.place (real estate, neighborhood subdomains) and floridahub.place (multi-vertical directory). Both .place renew at only ~$18/yr (Porkbun, verified 2026-08-23) — a mild premium over .org, comfortably inside budget for committed builds; not a cost concern despite the non-.org TLD.

3. Kenya Market

International/diaspora audiences pay more per visitor, so tourism + education + trade lead. discoverkenya.travel (Flagship, build inside the first-year window — high renewal). kenyanschools.org (High, cheap .org, unconditional hold). Plus kenyantrade.com, kenyacraft.shop, kenyafintech.com, kenyahub.io, kenyaheartbeat.com.

4. Federal Contracting & Supplier Diversity

Verified minority/women-owned directories feeding federal set-asides (8(a), WOSB, MBE) and corporate supplier-diversity sourcing. minoritysmbdirectory.org, wosbdirectory.org (federal front door), certifiedwbe.org (corporate front door), fedcontracts.courses (training).

5. Business & Service Directories

The lead-gen engine repeated: flbusinessdirectory.org, cleaningbusinessesdirectory.org, mobilemechanicsdirectory.org, mechanicsdirectory.org, smbdirectory.org.

6. Remote Work & Training

Standalone info-product. Primary: workfromhomecertification.org (highest search volume, cheap .org). Plus workfromhometraining.org, remoteworkcertification.academy.

7. AI Skills Training

Primary: promptmastery.academy (brandable, trademark-clean). aipromptmastery.academy held defensively as an SEO redirect. (Replaces the excluded ChatGPT-trademark name.)

8. Insurance Lead Generation

Auto insurance = highest-paying lead-gen niche. Primary: flcarinsurancelistings.org. State-regulated — confirm Florida lead-generator requirements before selling leads. .com variants held defensively.

9. Web & SaaS Products

instantwebsites.tech — productized subdomain-tenancy offering under MotionStack Studios. Product brand, not a defensible trademark.

10. Community & Networking

entrepreneurnetworking.org — memberships, event tickets, sponsor placements (Tukutane-model fit).

11. Faith & Ministry

faithchristianministries.org (nonprofit/donations) + .church (congregation). Confirm no collision with an established local ministry first.

12. Content & Niche — standalone

horoscopesandzodiacs.org (high-traffic AdSense/affiliate) and affiliatemarketingaggregator.org. Kept separate from the professional brand.


Excluded names — the guardrail

Some names were deliberately dropped for legal/trademark/reputational risk. Re-checking this list prevents re-acquiring a liability:


codeAmani notes

Official docs:

ElevenLabs Integration Guide

Technology: eleven-labs · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/eleven-labs

Insight:

ElevenLabs adds voice — TTS for IVR / voice-note replies and STT for transcribing user audio. Pairs with Africa's Talking Voice and WhatsApp voice notes; multilingual voices matter for Swahili/Sheng audiences. Keep the API key server-side and stream audio to stay responsive on low bandwidth.

███████╗██╗     ███████╗██╗   ██╗███████╗███╗   ██╗██╗      █████╗ ██████╗ ███████╗
██╔════╝██║     ██╔════╝██║   ██║██╔════╝████╗  ██║██║     ██╔══██╗██╔══██╗██╔════╝
█████╗  ██║     █████╗  ██║   ██║█████╗  ██╔██╗ ██║██║     ███████║██████╔╝███████╗
██╔══╝  ██║     ██╔══╝  ╚██╗ ██╔╝██╔══╝  ██║╚██╗██║██║     ██╔══██║██╔══██╗╚════██║
███████╗███████╗███████╗ ╚████╔╝ ███████╗██║ ╚████║███████╗██║  ██║██████╔╝███████║
╚══════╝╚══════╝╚══════╝  ╚═══╝  ╚══════╝╚═╝  ╚═══╝╚══════╝╚═╝  ╚═╝╚═════╝ ╚══════╝

ElevenLabs Integration Guide

Focus: Text-to-speech, voice cloning, speech-to-text, and voice design via ElevenLabs APIs and Claude Code tooling.

Overview

ElevenLabs provides state-of-the-art AI voice generation — synthesize speech, clone voices, transcribe audio, and design custom voices. Used in codeAmani products for voice UI features, audio notifications, and multilingual TTS (including Swahili).

Here is the big picture — text flows out as audio, and user audio flows back in as text, all through one API:

flowchart LR
  A["App text<br/>e.g. dashboard reply"] -->|"text_to_speech"| B["ElevenLabs API"]
  B --> C["Audio stream<br/>mp3"]
  C --> D["Play to user<br/>IVR or voice note"]
  E["User audio<br/>recording"] -->|"speech_to_text<br/>scribe_v2"| B
  B --> F["Transcribed text"]

Official Documentation

Resource URL
API Reference https://elevenlabs.io/docs/api-reference
Node.js SDK https://github.com/elevenlabs/elevenlabs-js
Python SDK https://github.com/elevenlabs/elevenlabs-python
Voice Library https://elevenlabs.io/voice-library
Models Reference https://elevenlabs.io/docs/models

MCP Server Setup

ElevenLabs offers two MCP paths. Prefer the hosted server — there is nothing to install and it authenticates over OAuth (no API key copied into a config file).

Note: there is no @elevenlabs/elevenlabs-mcp npm package (a common mistake — npx will 404). The self-hosted server is the Python package elevenlabs-mcp (PyPI), and its GitHub repo was archived on 2026-08-20 in favor of the hosted server below.

# Streamable-HTTP remote server; completes an OAuth sign-in on first connect
claude mcp add --transport http elevenlabs https://api.elevenlabs.io/v1/mcp

The hosted server focuses on ElevenLabs Agents management (list/create/update agents in your workspace). Revoke access any time from your ElevenLabs account settings.

Self-hosted (local Python server — creative tools)

For the creative toolset (TTS, STT, voice management, sound generation) run the elevenlabs-mcp PyPI package locally via uvx (requires the uv Python tool):

// .mcp.json
{
  "mcpServers": {
    "elevenlabs": {
      "command": "uvx",
      "args": ["elevenlabs-mcp"],
      "env": {
        "ELEVENLABS_API_KEY": "${ELEVENLABS_API_KEY}"
      }
    }
  }
}

MCP Capabilities (local elevenlabs-mcp server)

Tool Description
text_to_speech Convert text to audio using a specified voice
list_voices Browse available and cloned voices
get_voice Get details and settings for a voice
speech_to_text Transcribe audio files
sound_generation Generate sound effects from text

SDK Setup

Node.js / TypeScript

The npm package was renamed — the old bare elevenlabs package is deprecated ("moved to @elevenlabs/elevenlabs-js"). Install the scoped package (current @elevenlabs/elevenlabs-js is v2.64.0, SDK v2):

npm install @elevenlabs/elevenlabs-js
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const client = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});

Python

The Python package keeps the bare elevenlabs name (current v2.64.0):

pip install elevenlabs
from elevenlabs.client import ElevenLabs

client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])

Core Patterns

You are about to wire these up — here is how a streaming TTS request travels from your Next.js route to the listener:

sequenceDiagram
  participant Client
  participant Route as "API route<br/>/api/tts"
  participant EL as "ElevenLabs"
  Client->>Route: POST text + voiceId
  Route->>EL: textToSpeech.stream with modelId
  EL-->>Route: audio chunks
  Route-->>Client: audio/mpeg response
  Client->>Client: play audio

Text-to-Speech (Streaming)

import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });

// Stream audio to a file (v2 SDK: textToSpeech.stream, camelCase params)
const audioStream = await client.textToSpeech.stream(
  "JBFqnCBsd6RMkjVDRZzb", // Voice ID (Rachel — default)
  {
    text: "Karibu! Welcome to your codeAmani dashboard.",
    modelId: "eleven_multilingual_v2",
    voiceSettings: {
      stability: 0.5,
      similarityBoost: 0.75,
    },
  }
);

// Write to disk
import { createWriteStream } from "fs";
const writer = createWriteStream("output.mp3");
for await (const chunk of audioStream) {
  writer.write(chunk);
}
writer.end();

Next.js App Router API Route

// app/api/tts/route.ts
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import { NextRequest } from "next/server";

const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });

export async function POST(req: NextRequest) {
  const { text, voiceId = "JBFqnCBsd6RMkjVDRZzb" } = await req.json();

  const audioStream = await client.textToSpeech.stream(voiceId, {
    text,
    modelId: "eleven_multilingual_v2",
  });

  // Collect chunks
  const chunks: Buffer[] = [];
  for await (const chunk of audioStream) {
    chunks.push(Buffer.from(chunk));
  }

  return new Response(Buffer.concat(chunks), {
    headers: {
      "Content-Type": "audio/mpeg",
      "Cache-Control": "no-store",
    },
  });
}

List Available Voices

// v2 SDK: voices.search() (GET /v2/voices, paginated). voices.getAll() still
// works as a legacy alias but search() is the current method.
const { voices } = await client.voices.search();

for (const voice of voices) {
  console.log(`${voice.voice_id}: ${voice.name} (${voice.labels?.language ?? "multi"})`);
}

Speech-to-Text (Transcription)

import { createReadStream } from "fs";

const transcription = await client.speechToText.convert({
  file: createReadStream("recording.mp3"),
  modelId: "scribe_v2", // current STT model (scribe_v1 is deprecated)
  languageCode: "sw", // Swahili
});

console.log(transcription.text);

Voice cloning

Instant Voice Cloning (IVC) turns a short audio sample into a reusable voice. You upload one or more recordings, get back a voice_id, then synthesize speech with it like any other voice. This is how you give a codeAmani product its own branded voice — or let a user respond in their own voice for voice-note replies.

The flow is two API calls — clone once, reuse the voice_id forever:

flowchart LR
  A["Audio samples<br/>clean · 1+ min"] -->|"voices.ivc.create"| B["ElevenLabs"]
  B --> C["voice_id<br/>saved to account"]
  C -->|"textToSpeech.convert"| D["Synthesized audio<br/>in cloned voice"]
  C --> E["Store voice_id<br/>in your DB"]

Create a cloned voice, then synthesize with it

import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import fs from "fs";

const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });

// 1. Clone a voice from one or more audio samples
const cloned = await client.voices.ivc.create({
  name: "codeAmani Brand Voice",
  files: [
    fs.createReadStream("samples/sample-1.mp3"),
    fs.createReadStream("samples/sample-2.mp3"),
  ],
});

const voiceId = cloned.voice_id;
console.log("Cloned voice id:", voiceId);
// Persist voiceId in your DB so you can reuse it without re-cloning.

// 2. Synthesize speech using the returned voice_id
const audio = await client.textToSpeech.convert(voiceId, {
  text: "Karibu! Hii ni sauti yako mpya kwenye codeAmani.",
  modelId: "eleven_multilingual_v2",
  outputFormat: "mp3_44100_128",
});

const chunks: Buffer[] = [];
for await (const chunk of audio) {
  chunks.push(Buffer.from(chunk));
}
fs.writeFileSync("cloned-output.mp3", Buffer.concat(chunks));

The create call returns an AddVoiceIVCResponseModel with voice_id (use this everywhere) and requires_verification (whether the voice must be verified before high-volume use). The cloned voice also appears in client.voices.getAll().

Gotcha — consent and sample quality. Only clone voices you have explicit permission to use; cloning a real person's voice without consent breaks ElevenLabs' terms (and KDPA-style consent expectations for biometric/voice data). Quality is bounded by your samples: use clean, single-speaker recordings with no background noise or music — a minute of crisp audio beats ten minutes of noisy phone audio. Keep the API key server-side; never expose it to the client doing the upload.


Model Reference

Model ID Use Case Languages Latency
eleven_v3 Flagship — most expressive/emotional TTS, long-form 70+ higher
eleven_v3_conversational Expressive real-time TTS for voice agents 70+ ~280ms
eleven_multilingual_v2 Lifelike, consistent high-quality TTS 29 ~1s
eleven_flash_v2_5 Lowest latency / cheapest (50% off per char), bulk & real-time 32 ~75ms
scribe_v2 Speech-to-text transcription (batch) 90+ —
scribe_v2_realtime Streaming speech-to-text 90+ ~150ms

Deprecated / superseded (do not use in new code): eleven_turbo_v2_5 and eleven_turbo_v2 — ElevenLabs recommends the Flash models in all use cases (functionally equivalent, lower latency). scribe_v1 → migrate to scribe_v2.

For Swahili TTS, eleven_multilingual_v2 remains the proven choice for quality; eleven_v3 (70+ languages) is the newer, more expressive option and eleven_flash_v2_5 (32 languages) covers low-latency/real-time Swahili.


Webhooks

ElevenLabs sends webhook events for async operations (e.g. batch speech-to-text, post-call transcripts). The signature is HMAC-SHA256 in the ElevenLabs-Signature header, formatted t=<unix_ts>,v0=<hex_hmac>, where the signed message is `${timestamp}.${rawBody}` (not the raw body alone). Reject requests whose timestamp is outside a ~30-minute window to block replays.

Prefer the SDK helper, which verifies the signature, checks the timestamp window, and parses the payload for you:

// app/api/webhooks/elevenlabs/route.ts
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import { NextRequest } from "next/server";

const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sigHeader = req.headers.get("ElevenLabs-Signature") ?? "";
  const secret = process.env.ELEVENLABS_WEBHOOK_SECRET!;

  let event;
  try {
    event = await client.webhooks.constructEvent(body, sigHeader, secret);
  } catch {
    return new Response("Unauthorized", { status: 401 });
  }

  // Handle event.type, e.g. "speech_to_text_transcription.completed"
  return new Response("OK");
}

If you verify manually, recompute HMAC_SHA256(secret, ${t}.${body}) and compare (constant-time) against the v0= value parsed from the header.


Environment Variables

# Required
ELEVENLABS_API_KEY=sk_...

# Optional
ELEVENLABS_WEBHOOK_SECRET=whsec_...
ELEVENLABS_DEFAULT_VOICE_ID=JBFqnCBsd6RMkjVDRZzb

Common Use Cases

Use Case Approach
Voice notifications POST to /api/tts, play on client
Swahili audio content eleven_multilingual_v2 + languageCode: "sw"
Voice cloning voices.ivc.create() with audio samples → get voice_id → use in TTS (see Voice cloning)
Audio transcription scribe_v2 model + speechToText.convert()
Real-time voice AI WebSocket streaming with eleven_flash_v2_5 (or eleven_v3_conversational)

Troubleshooting

Issue Fix
401 Unauthorized Check ELEVENLABS_API_KEY is set and valid
Audio sounds robotic Increase stability (0.7–0.9) and similarity_boost (0.8)
Swahili not accurate Use eleven_multilingual_v2 (or eleven_v3); Flash trades some quality for latency
Large audio files slow Use streaming (textToSpeech.stream) instead of buffered response
MCP server not connecting Run claude mcp list and check ELEVENLABS_API_KEY in env

Official docs:

Encryption Integration Guide

Technology: encryption · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/encryption

Insight:

Encryption is not one primitive but four categories, and the fatal mistake is reaching for the wrong one — hashing data you must read back, or "encrypting" a password you should never recover. Get the category right first (symmetric AES-256-GCM for data you decrypt, hashing/Argon2id for passwords you never can, sealed boxes to encrypt to a public key, envelope encryption for keys at scale), then let an audited library — Web Crypto, libsodium, @noble/ciphers, Tink — do the math; OWASP on rolling your own is blunt: don't. For codeAmani the load-bearing case is field-level AES-256-GCM over DoseVault PHI (HIPAA) and all PII (KDPA 2019), so the database only ever holds ciphertext, plus masking and tokenization for M-Pesa identifiers in every log.

███████╗███╗   ██╗ ██████╗██████╗ ██╗   ██╗██████╗ ████████╗██╗ ██████╗ ███╗   ██╗
██╔════╝████╗  ██║██╔════╝██╔══██╗╚██╗ ██╔╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗  ██║
█████╗  ██╔██╗ ██║██║     ██████╔╝ ╚████╔╝ ██████╔╝   ██║   ██║██║   ██║██╔██╗ ██║
██╔══╝  ██║╚██╗██║██║     ██╔══██╗  ╚██╔╝  ██╔═══╝    ██║   ██║██║   ██║██║╚██╗██║
███████╗██║ ╚████║╚██████╗██║  ██║   ██║   ██║        ██║   ██║╚██████╔╝██║ ╚████║
╚══════╝╚═╝  ╚═══╝ ╚═════╝╚═╝  ╚═╝   ╚═╝   ╚═╝        ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

Encryption Integration Guide

Focus: pick the right primitive for the job and let an audited library do the math. Symmetric (AES-256-GCM) for data you can decrypt, hashing (Argon2id) for passwords you never can, sealed boxes for "encrypt to a public key", and envelope encryption for keys at scale. For codeAmani the load-bearing case is masking PII and protecting HIPAA/PHI in DoseVault — field-level encryption and tokenization on top of these primitives.

Overview

Encryption is not one thing. The single most common mistake is reaching for the wrong tool: hashing a credit-card number you need to charge later, "encrypting" a password you should never be able to read back, or hand-rolling AES with a static IV. Get the category right first, then the implementation is almost mechanical.

Four categories cover almost everything you'll touch:

  1. Symmetric encryption — one secret key encrypts and decrypts. Fast, used for bulk data. Default: AES-256-GCM (or XChaCha20-Poly1305). Reversible — you can get the plaintext back.
  2. Asymmetric encryption — a public key encrypts, a separate private key decrypts. Solves the "how do I share a key with someone I've never met" problem. RSA (legacy) or ECC/Curve25519 (preferred, smaller, faster).
  3. Hashing — a one-way function. Not encryption — there is no key and no way back. Used for password storage (Argon2id) and integrity (SHA-256). If you can "decrypt" a hash, it wasn't a hash.
  4. E2EE — end-to-end: only the two endpoints hold keys; the server relays ciphertext it cannot read. Built from the above (sealed boxes for one-shot, double-ratchet for live messaging).

Layered on top of where the data lives: in transit (TLS — handled by the platform, just enforce HTTPS) vs at rest (your job — field-level encryption, envelope/KMS).

Golden rule, straight from OWASP: don't roll your own crypto. Use AES-GCM via the Web Crypto API (built into browsers and Node 20+), libsodium (crypto_box_seal, crypto_secretbox), @noble/*, or Google Tink. These are audited, constant-time, and hard to misuse. Hand-written ECB-mode loops and static IVs are how data leaks.

Official Documentation

Source URL What it covers
MDN — SubtleCrypto.encrypt https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt Web Crypto AES-GCM encrypt/decrypt, IV rules
MDN — SubtleCrypto.generateKey https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey Key generation for AES, RSA, ECDH
libsodium — Sealed boxes https://doc.libsodium.org/public-key_cryptography/sealed_boxes Anonymous public-key encryption (X25519 + XSalsa20-Poly1305)
libsodium.js https://github.com/jedisct1/libsodium.js JS wrapper API, sodium.ready, function names
OWASP Cryptographic Storage https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html Algorithm/key-length choices, envelope encryption, KMS
OWASP Password Storage https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html Argon2id / scrypt / bcrypt parameters

The taxonomy at a glance

Category Reversible? Key model Reach for codeAmani example
Symmetric Yes one shared key AES-256-GCM Encrypt a PHI column at rest
Asymmetric Yes public + private ECC/Curve25519, RSA-OAEP Encrypt to a recipient's public key
Hashing No none Argon2id (passwords), SHA-256 (integrity) Store login passwords
E2EE Yes (endpoints only) per-party keys sealed box / double ratchet Patient-to-clinician message

Encryption vs hashing — the one distinction to never blur: encryption is reversible with the key; hashing is a one-way trapdoor with no key. Passwords get hashed (you only ever compare hashes), never encrypted. M-Pesa amounts you need to display later get encrypted. If you ever find yourself wanting to "decrypt the password to email it to the user," stop — that's a design bug.


Symmetric: AES-256-GCM with the Web Crypto API

AES-256-GCM is the OWASP-recommended default for data at rest. GCM is an authenticated mode — it detects tampering, so you get confidentiality and integrity in one call. Web Crypto is built into browsers and Node 20+, so there's no dependency.

Two non-negotiable rules:

// lib/crypto/aes-gcm.ts — AES-256-GCM via Web Crypto (Node 20+ / Edge / browser)
// `crypto` is the global Web Crypto object; no import needed in Node 20+ / Edge.

const IV_BYTES = 12; // GCM standard; MUST be unique per message under one key

/** Import a 32-byte (256-bit) raw key as an AES-GCM CryptoKey. */
async function importKey(raw: Uint8Array): Promise<CryptoKey> {
  return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
    "encrypt",
    "decrypt",
  ]);
}

/** Encrypt UTF-8 text → { iv, ciphertext } (both base64). GCM tag is appended to ciphertext. */
export async function encrypt(plaintext: string, rawKey: Uint8Array) {
  const key = await importKey(rawKey);
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); // fresh every call
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    new TextEncoder().encode(plaintext),
  );
  return { iv: b64(iv), ciphertext: b64(new Uint8Array(ct)) };
}

/** Decrypt; throws if the tag fails (tampered/wrong key) — never returns garbage. */
export async function decrypt(
  payload: { iv: string; ciphertext: string },
  rawKey: Uint8Array,
): Promise<string> {
  const key = await importKey(rawKey);
  const pt = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: unb64(payload.iv) },
    key,
    unb64(payload.ciphertext),
  );
  return new TextDecoder().decode(pt);
}

const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
const unb64 = (s: string) => Buffer.from(s, "base64");

You can bind extra context to the ciphertext with GCM's additionalData (AAD) — e.g. pass the row's patient_id as AAD so a ciphertext copied to another row fails to decrypt. Cheap, strong defence against ciphertext-swapping.


Asymmetric: encrypting to a public key

When you need to encrypt for someone without a pre-shared secret, use public-key crypto. OWASP's preference is ECC (Curve25519) over RSA — smaller keys, faster, fewer footguns. If you must use RSA, use RSA-OAEP with ≥2048-bit keys (OAEP padding is mandatory; textbook/PKCS#1 v1.5 is broken in practice).

The cleanest API for "encrypt to a public key" is libsodium sealed boxes (X25519 + XSalsa20-Poly1305). The sender needs only the recipient's public key; libsodium generates a throwaway ephemeral keypair per message, so the ciphertext is anonymous — the recipient can verify integrity but cannot learn who sent it.

// lib/crypto/sealed-box.ts — anonymous public-key encryption with libsodium
import sodium from "libsodium-wrappers";

/** Recipient generates a keypair once; publishes publicKey, keeps privateKey secret. */
export async function newKeypair() {
  await sodium.ready;
  const { publicKey, privateKey } = sodium.crypto_box_keypair();
  return { publicKey, privateKey }; // Uint8Array, 32 bytes each
}

/** Anyone with the recipient's public key can seal a message to them. */
export async function seal(message: string, recipientPublicKey: Uint8Array) {
  await sodium.ready;
  // ephemeral keypair is generated + erased internally; sender stays anonymous
  return sodium.crypto_box_seal(sodium.from_string(message), recipientPublicKey);
}

/** Only the recipient (holding both keys) can open it. */
export async function open(
  sealed: Uint8Array,
  recipientPublicKey: Uint8Array,
  recipientPrivateKey: Uint8Array,
): Promise<string> {
  await sodium.ready;
  const opened = sodium.crypto_box_seal_open(
    sealed,
    recipientPublicKey,
    recipientPrivateKey,
  );
  return sodium.to_string(opened);
}

Always await sodium.ready before any sodium.* call — the WASM module loads asynchronously. Calling early throws.


Hashing: passwords and integrity (not encryption)

Hashing is one-way. Two distinct jobs:

// lib/crypto/password.ts — server-side ONLY (native binding, never in the browser)
import * as argon2 from "argon2";

export const hashPassword = (plain: string) =>
  argon2.hash(plain, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 });

/** Constant-time compare is handled inside verify(); returns boolean, never throws on mismatch. */
export const verifyPassword = (hash: string, plain: string) =>
  argon2.verify(hash, plain);

With Clerk as the auth provider you usually don't store passwords at all — Clerk owns that. Hash passwords yourself only for systems Clerk doesn't cover.


In transit vs at rest

In transit At rest
Threat Network eavesdropper / MITM Stolen DB dump / backup / disk
Mechanism TLS 1.2+ (HTTPS) Disk/column/field encryption
Who handles it Platform (Vercel, Cloudflare) — just enforce HTTPS You (envelope encryption, field-level)

TLS protects bytes on the wire; it does nothing for a leaked database. PHI/PII needs both: TLS in transit and application-layer encryption at rest. Disk-level encryption (Supabase/Neon at-rest) protects against a stolen disk but not against a compromised app role that can SELECT plaintext — which is why field-level encryption matters below.


Envelope encryption & KMS

You don't encrypt millions of rows with one master key sitting in an env var. The OWASP pattern is envelope encryption:

This means key rotation = re-wrap the DEK (cheap), not re-encrypt all data. A leaked wrapped-DEK is useless without KMS access.

flowchart LR
    PT["Plaintext PHI"] -->|"AES-256-GCM"| CT["Ciphertext"]
    DEK["Data key (DEK)"] -->|encrypts| CT
    KMS["KMS / HSM holds KEK"] -->|"wraps DEK"| WDEK["Wrapped DEK"]
    CT --> DB[("Database row")]
    WDEK --> DB
    note["KEK never leaves the KMS"] -.-> KMS

Rotation triggers per OWASP: suspected compromise, end of cryptoperiod, or data-volume thresholds.


PII / PHI masking & field-level encryption

This is the codeAmani core. DoseVault handles PHI under HIPAA; everything we run touches PII under Kenya's KDPA 2019. Disk encryption alone is not enough — once the app connects, the data is plaintext to anyone with a query. Three complementary tools:

1. Field-level (application-layer) encryption

Encrypt sensitive columns with AES-256-GCM before they hit the database, decrypt on read in the app. The DB only ever sees ciphertext, so a leaked dump (or an over-broad RLS hole) exposes nothing. Use a per-field DEK via envelope encryption, and bind the row id as AAD so ciphertext can't be moved between rows.

// Storing a PHI field — encrypt at the app boundary, store {iv, ciphertext}
const { iv, ciphertext } = await encrypt(patient.diagnosis, dek);
await db.from("records").insert({ patient_id, diagnosis_iv: iv, diagnosis_ct: ciphertext });

In Postgres/Supabase, prefer this app-layer approach over pgcrypto for PHI: keys stay out of the database entirely, so a DB compromise never yields keys. Reserve pgcrypto/pgsodium for cases where the DB must do the crypto.

2. Data masking

Show a non-reversible partial view for display, logs, and support tools — the real value is never rendered. This is presentation, not security on its own, but it shrinks how often plaintext is exposed.

// Mask an M-Pesa phone (always 254XXXXXXXXX) and an ID for UI / logs
export const maskPhone = (p: string) => p.replace(/^(\d{3})\d{6}(\d{3})$/, "$1******$2"); // 254******149
export const maskId = (id: string) => id.length <= 4 ? "****" : "****" + id.slice(-4);

Never log raw PII/PHI or M-Pesa identifiers. Mask CheckoutRequestID, phone numbers, and patient ids before they reach Sentry or app logs. Structured logging only — no console.log of payloads.

3. Tokenization

Replace a sensitive value with a meaningless token; keep the real value in a separate, locked-down vault. Your main DB and analytics store only tokens. Unlike encryption, the token carries no recoverable data — ideal when most systems never need the real value (e.g. an internal ref instead of a phone number across services). It also shrinks PCI/compliance scope, since the sensitive data lives in one auditable place.

flowchart LR
    U["Patient submits PHI"] -->|TLS in transit| API["API route"]
    API -->|"AES-256-GCM (field-level)"| ENC["Encrypt PHI columns"]
    ENC --> DB[("DB: ciphertext + masked refs")]
    API -->|"mask for logs"| LOG["Sentry / logs see masked only"]
    DB -->|"decrypt in app on read"| VIEW["Authorised clinician view"]

E2EE in one paragraph

End-to-end encryption means the server only ever holds ciphertext. For one-shot messages, a sealed box (above) is genuine E2EE: encrypt to the recipient's public key client-side, the server stores opaque bytes, only the recipient's private key opens it. For live, ongoing conversations, Signal's double ratchet adds forward secrecy (a leaked key doesn't expose past messages) and post-compromise security by deriving a fresh key per message — conceptually a key that "ratchets" forward and can't be wound back. Don't implement the ratchet yourself; use libsignal or a vetted library. For DoseVault patient↔clinician messaging, sealed boxes cover the common case without that complexity.


Security checklist


codeAmani notes

Official docs:

Figma Integration Guide

Technology: figma · Category: design · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/figma

Insight:

The Figma MCP bridges design and code both ways — pull a frame into accurate component code, or push code into Figma. Use get_design_context / screenshots to ground UI work in the real design instead of guessing, keeping the motionstack design system consistent.

███████╗██╗ ██████╗ ███╗   ███╗ █████╗
██╔════╝██║██╔════╝ ████╗ ████║██╔══██╗
█████╗  ██║██║  ███╗██╔████╔██║███████║
██╔══╝  ██║██║   ██║██║╚██╔╝██║██╔══██║
██║     ██║╚██████╔╝██║ ╚═╝ ██║██║  ██║
╚═╝     ╚═╝ ╚═════╝ ╚═╝     ╚═╝╚═╝  ╚═╝

Figma Integration Guide

Focus: Design-to-code workflows, component inspection, and Figma canvas automation from Claude Code using the official Figma MCP server.

Overview

Figma is the standard design tool for UI/UX. Its official MCP server gives Claude Code direct access to design files — reading component specs, extracting tokens, generating code from components, writing content back to the canvas, and running Code Connect mappings. This enables a true design-to-code pipeline: Claude reads Figma, writes the component, and links it back.

Here is the big picture — the MCP bridges design and code both ways, and you get to use both directions:

flowchart LR
    A["Figma design file"] -->|"read"| B["Figma MCP server"]
    B --> C["Claude Code"]
    C -->|"generate"| D["React or Vue component"]
    C -->|"write canvas"| A
    D -->|"link back"| E["Code Connect map"]
    E --> A

Official Documentation

Resource URL
Figma Developers https://developers.figma.com
Figma MCP Server https://developers.figma.com/docs/figma-mcp-server/
Set up the MCP server in Claude Code https://help.figma.com/hc/en-us/articles/39888612464151-Claude-Code-and-Figma-Set-up-the-MCP-server
Write to Canvas (use_figma) https://developers.figma.com/docs/figma-mcp-server/write-to-canvas/
Code to Canvas (generate_figma_design) https://developers.figma.com/docs/figma-mcp-server/code-to-canvas/
MCP Help Guide https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server
Figma REST API https://developers.figma.com/docs/rest-api/
Webhooks (REST API v2) https://developers.figma.com/docs/rest-api/webhooks/
Code Connect https://developers.figma.com/docs/code-connect/

MCP Server Setup

Figma recommends the hosted remote MCP server — it requires no desktop app, provides the broadest feature set, and is now available on all seats and plans. The endpoint is https://mcp.figma.com/mcp and auth is OAuth (an interactive "Allow access" browser flow) — there is no longer a static X-Figma-Token header on the MCP server itself. (The old mcp.figma.com/v1/mcp path is gone.)

Recommended path — install the official Figma plugin (bundles the MCP server config plus Agent Skills):

claude plugin install figma@claude-plugins-official
# then restart Claude Code, run /plugin, open the Installed tab,
# select the `figma` server, press Enter, and click "Allow access" to authorize (OAuth)

Manual alternative — add the remote server directly:

# Triggers the OAuth "Allow access" flow on first use — no token header needed
claude mcp add --transport http figma https://mcp.figma.com/mcp

.mcp.json Configuration (Remote)

{
  "mcpServers": {
    "figma": {
      "type": "http",
      "url": "https://mcp.figma.com/mcp"
    }
  }
}

On first connection Claude Code opens the OAuth flow; approve access to your Figma account. No personal access token is stored for the MCP server.

Desktop (local) MCP Server

For enterprise/org needs Figma also ships a local server inside the Figma desktop app (enable it under Preferences → "Enable local MCP server"). It is available on a Dev or Full seat on paid plans and listens on http://127.0.0.1:3845/mcp:

claude mcp add --transport http figma-desktop http://127.0.0.1:3845/mcp

Third-party Community Server (figma-developer-mcp)

figma-developer-mcp (the community "Framelink" server, currently 0.13.2) is a third-party stdio server — not Figma's official MCP. It authenticates with a personal access token and can be handy for read-only REST-backed workflows:

claude mcp add figma-framelink -- npx -y figma-developer-mcp \
  --figma-api-key=${FIGMA_ACCESS_TOKEN} \
  --stdio

Prefer the official remote server above for design-context and write-to-canvas work. A personal access token (for the REST API or this community server) is generated at: https://www.figma.com/settings → Personal access tokens

Available MCP Tools

Tool Description
get_design_context Get full design specs, code hints, and screenshot for a node
get_screenshot Capture a screenshot of a Figma node
get_metadata Get file metadata (name, pages, last modified)
get_figjam Get FigJam board content
get_libraries Get shared component libraries
search_design_system Search for components by name
get_variable_defs Get design tokens (colors, spacing, typography)
get_code_connect_map Map Figma components to codebase components
add_code_connect_map Link a Figma component to a code component
generate_diagram Create a diagram in FigJam
download_assets Export nodes as PNG / SVG / PDF via the MCP

The server's tool set keeps growing — recent additions include motion, shader, and Weave (weave_*) tools. Run /plugin (or your client's tool list) to see the current inventory; the standalone get_design_pages tool was folded into get_metadata.


Figma REST API Integration

npm install axios  # or use fetch

Extract Component Info

const FIGMA_TOKEN = process.env.FIGMA_ACCESS_TOKEN!;
const FILE_KEY = "your-file-key"; // from figma.com/design/{fileKey}/...

// Get file structure
const file = await fetch(`https://api.figma.com/v1/files/${FILE_KEY}`, {
  headers: { "X-Figma-Token": FIGMA_TOKEN },
}).then((r) => r.json());

// Get specific node
const nodeId = "1:23"; // from URL param node-id=1-23
const nodes = await fetch(
  `https://api.figma.com/v1/files/${FILE_KEY}/nodes?ids=${nodeId}`,
  { headers: { "X-Figma-Token": FIGMA_TOKEN } }
).then((r) => r.json());

// Export node as PNG
const images = await fetch(
  `https://api.figma.com/v1/images/${FILE_KEY}?ids=${nodeId}&format=png&scale=2`,
  { headers: { "X-Figma-Token": FIGMA_TOKEN } }
).then((r) => r.json());

console.log(images.images[nodeId]); // URL to the exported PNG

REST file/nodes/images endpoints are still under /v1 and still authenticate with the X-Figma-Token header (OAuth2 is also supported). No /v1 deprecation is in effect as of this review.

Webhooks (REST API v2)

Figma webhooks live at https://api.figma.com/v2/webhooks (note: v2, not v1) — subscribe to file events (FILE_UPDATE, FILE_COMMENT, FILE_VERSION_UPDATE, LIBRARY_PUBLISH, etc.). There is no HMAC signature header: you set a passcode when creating the webhook, Figma echoes it in every event payload, and your handler must compare the incoming passcode against the stored one and reject mismatches with 400 before acting — this is the codeAmani webhook-verification requirement applied to Figma.

// app/api/webhooks/figma/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.json();
  // Verify the request really came from Figma before doing any work
  if (body.passcode !== process.env.FIGMA_WEBHOOK_PASSCODE) {
    return new NextResponse("Invalid passcode", { status: 400 });
  }
  // handle body.event_type (FILE_UPDATE, FILE_COMMENT, ...)
  return NextResponse.json({ ok: true });
}

Environment Variables

# Required
FIGMA_ACCESS_TOKEN=figd_...         # Personal access token from Figma settings

# Optional for automation scripts
FIGMA_FILE_KEY=...                  # Default file key for automation
FIGMA_TEAM_ID=...                   # Team ID for shared library access
FIGMA_WEBHOOK_PASSCODE=...          # Passcode to verify inbound v2 webhook events

Automation Workflows

Design-to-Code Workflow

The core Claude Code + Figma workflow follows four clean steps — here is how the calls flow:

sequenceDiagram
    participant You
    participant Claude as "Claude Code"
    participant MCP as "Figma MCP"
    You->>Claude: "Share Figma URL or node ID"
    Claude->>MCP: "get_design_context"
    MCP-->>Claude: "specs, colors, spacing, screenshot"
    Claude->>Claude: "generate React or Vue component"
    Claude->>MCP: "add_code_connect_map"
    MCP-->>Claude: "Figma component linked"
  1. Share a Figma URL or node ID
  2. Claude calls get_design_context → gets specs, colors, spacing, component screenshot
  3. Claude generates a React/Vue component matching the design
  4. Claude calls add_code_connect_map → links the Figma component to the generated component

Example prompt inside Claude Code:

"Implement the Button/Primary component from figma.com/design/AbCdEf/Design-System?node-id=1:23"

Slash Command: Figma to Component

.claude/commands/figma.md:

Implement the Figma design at URL or node: $ARGUMENTS

1. Use the Figma MCP tool `get_design_context` with the file key and node ID extracted from $ARGUMENTS
2. Use `get_screenshot` to see the visual
3. Generate a TypeScript React component that matches the design exactly:
   - Use Tailwind CSS for styling
   - Extract all colors as CSS variables or Tailwind tokens
   - Make it responsive
   - Add proper TypeScript props interface
4. Write the component to the appropriate file in `src/components/`
5. Create a Storybook story for it
6. Report the component code and any design tokens used

Usage: /project:figma figma.com/design/AbCdEf/Design-System?node-id=1-23


Code → design (write to canvas)

The MCP is not read-only. Claude Code can write native Figma structure back into a file — real frames, components, variants, variables, and auto layout — not just flat screenshots. This is the inverse of the design-to-code flow above and is what keeps the motionstack design system in sync when code moves ahead of the canvas.

Three official write tools cover this direction:

Tool Direction What it produces
create_new_file scaffold A fresh, blank Figma / FigJam / Slides file to write into
use_figma code/intent → canvas Native Figma objects via the Plugin API — components, variables, frames, auto layout, with awareness of the existing design system
generate_figma_design running app → canvas "Code to canvas" — captures live rendered UI from the browser as standard, flat Figma layers for human review

MANDATORY skill note: You MUST load the /figma-use skill before every use_figma call, and the /figma-create-new-file skill before every create_new_file call. Calling these tools without first loading the matching skill causes common, hard-to-debug failures. For pushing a whole page or layout, also load /figma-generate-design.

Workflow: generate a component into Figma from a description or code

sequenceDiagram
    participant You
    participant Claude as "Claude Code"
    participant Skill as "figma-use skill"
    participant MCP as "Figma MCP"
    You->>Claude: "Build Button/Primary in Figma"
    Claude->>Skill: "load figma-use"
    Claude->>MCP: "search_design_system · get_variable_defs"
    MCP-->>Claude: "existing tokens and components"
    Claude->>MCP: "use_figma · Plugin API code"
    MCP-->>Claude: "native component on canvas"
  1. (If no target file) load /figma-create-new-file, then call create_new_file to scaffold a blank file
  2. Load /figma-use — this is required before the next step
  3. Discover what already exists: search_design_system plus get_variable_defs so the new work reuses real tokens and components instead of hardcoded values
  4. Call use_figma, which executes Plugin API JavaScript inside the file to assemble the component section-by-section, binding design-system variables
  5. For a full app screen instead of one component, capture the running UI with generate_figma_design ("code to canvas") for the team to review before implementation

Example prompt inside Claude Code:

"Create a Button/Primary component in our design-system file from src/components/Button.tsx, reusing our existing color and spacing variables."

Gotcha: use_figma is beta and intentionally limited — there is a ~20 KB response cap per call, no image / asset import, no custom fonts, and a Full seat with edit access is required (Dev seats are read-only). Make large changes incrementally across several calls rather than one giant payload, and expect to manually review and clean up the result.

Code Connect creates a permanent mapping between Figma components and real code so Dev Mode shows actual component usage instead of raw CSS.

Heads-up — Code Connect v2 (Aug 2026). @figma/code-connect is now 2.0.0. As of v2.0.0 (18 Aug 2026) the framework-specific parsers (the React .figma.tsx form below with an example: () => <JSX/> function) are no longer maintained — under v2 they only work with figma connect migrate/unpublish, and all other commands tell you to migrate. Parserless template files (.figma.ts / .figma.js) are now the only actively-maintained format. Author them with the shipped /figma-code-connect skill and follow the templates migration guide. npx figma connect publish is unchanged. To keep the legacy React parser, pin v1: npm install --save-dev @figma/code-connect@1.

Current (v2) — parserless template file (Button.figma.ts):

// Button.figma.ts — v2 template file (no framework parser)
/**
 * @figmaNode https://www.figma.com/design/[FILE_KEY]?node-id=[NODE_ID]
 */
import figma from "figma";
import { Button } from "./Button";

export default figma.connect(Button, {
  props: {
    variant: figma.enum("Variant", { Primary: "primary", Secondary: "secondary" }),
    disabled: figma.boolean("Disabled"),
    label: figma.string("Label"),
  },
  example: ({ variant, disabled, label }) => (
    <Button variant={variant} disabled={disabled}>{label}</Button>
  ),
});

Legacy (v1 React parser) — requires @figma/code-connect@1:

// Button.figma.tsx — legacy React parser (pin @figma/code-connect@1)
import { figma } from "@figma/code-connect/react";
import { Button } from "./Button";

figma.connect(Button, "https://www.figma.com/design/[FILE_KEY]?node-id=[NODE_ID]", {
  props: {
    variant: figma.enum("Variant", { Primary: "primary", Secondary: "secondary" }),
    disabled: figma.boolean("Disabled"),
    label: figma.string("Label"),
  },
  example: ({ variant, disabled, label }) => (
    <Button variant={variant} disabled={disabled}>{label}</Button>
  ),
});
# Publish Code Connect mappings to Figma (unchanged in v2)
npx figma connect publish

GitHub Actions: Auto-generate Types from Design Tokens

# .github/workflows/design-tokens.yml
name: Sync Figma Tokens
on:
  schedule:
    - cron: "0 9 * * 1"   # Every Monday morning
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - name: Fetch design tokens
        env:
          FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }}
          FIGMA_FILE_KEY: ${{ secrets.FIGMA_FILE_KEY }}
        run: node scripts/sync-tokens.js
      - name: Commit updated tokens
        run: |
          git config user.name "Design Sync Bot"
          git config user.email "noreply@figma.com"
          git add src/tokens/
          git diff --staged --quiet || git commit -m "chore: sync Figma design tokens"
          git push

Common Use Cases

Use Case Approach
Design to React component MCP get_design_context → generate code
Component library sync get_libraries + search_design_system
Design token extraction MCP get_variable_defs → CSS/TypeScript
Visual regression check get_screenshot → compare to rendered component
Code Connect linking add_code_connect_map + figma connect publish
FigJam diagrams MCP generate_diagram

Troubleshooting

Issue Fix
403 Forbidden Token lacks access to that file — check sharing settings
Node not found Extract node-id from URL; convert - to : (e.g., 1-23 → 1:23)
Design context empty Component may be in a library — use get_libraries first
Screenshot fails Node must be visible (not hidden) in the file
Code Connect not publishing Run npx figma connect publish from project root
figma connect errors telling you to migrate You're on v2 with legacy .figma.tsx parser files — migrate to template files (/figma-code-connect skill) or pin @figma/code-connect@1
MCP server won't connect Remote endpoint is https://mcp.figma.com/mcp (not /v1/mcp); re-run the OAuth "Allow access" flow via /plugin
Figma webhook events ignored Verify passcode matches before acting; reject mismatches with 400

Official docs:

Firecrawl Integration Guide

Technology: firecrawl · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/firecrawl

Insight:

Firecrawl is the LLM-ready web data API — give it a URL (or a search query) and get back clean markdown / structured JSON, with proxies, anti-bot, and JavaScript rendering already handled. It's the managed alternative to hand-rolled Playwright/Puppeteer for RAG ingestion and agent web-browsing. Five primitives — scrape, crawl, map, search, extract — cover single-page, whole-site, URL-discovery, web-search, and schema-to-JSON. Bill per credit (1 credit = 1 page); map before crawl and onlyMainContent keep both cost and token counts low. Verified against docs.firecrawl.dev on 2026-08-23 — SDK v4.x (npm firecrawl 4.35.0 / pip firecrawl-py 4.38.0) on the v2 REST API. Docs install firecrawl; the legacy scoped alias @mendable/firecrawl-js still publishes the same 4.x releases in lockstep (it is not deprecated — only the v1 method names like crawlUrl are).

███████╗██╗██████╗ ███████╗ ██████╗██████╗  █████╗ ██╗    ██╗██╗
██╔════╝██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗██║    ██║██║
█████╗  ██║██████╔╝█████╗  ██║     ██████╔╝███████║██║ █╗ ██║██║
██╔══╝  ██║██╔══██╗██╔══╝  ██║     ██╔══██╗██╔══██║██║███╗██║██║
██║     ██║██║  ██║███████╗╚██████╗██║  ██║██║  ██║╚███╔███╔╝███████╗
╚═╝     ╚═╝╚═╝  ╚═╝╚══════╝ ╚═════╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚══╝╚══╝ ╚══════╝

Firecrawl Integration Guide

Focus: Turning any URL — or a web search — into clean, LLM-ready markdown or schema-validated JSON, with proxies, anti-bot, and JS rendering handled for you. The managed alternative to hand-rolled Playwright/Puppeteer for RAG and agents.

Verification note (2026-08-23): Every endpoint path, SDK method name, credit cost, price, and rate-limit number below was live-verified against docs.firecrawl.dev. Firecrawl is on the v2 REST API, driven by the v4.x SDKs — firecrawl (npm, 4.35.0) / firecrawl-py (pip, 4.38.0). The docs install the unscoped firecrawl package; the older scoped name @mendable/firecrawl-js is not deprecated — it publishes the identical 4.x releases in lockstep (both point at github.com/firecrawl/firecrawl). What is deprecated is the v1 SDK method names (crawlUrl, scrapeUrl, asyncCrawlUrl) from SDK ≤1.x. Two facts shifted since the last review: prices rose (Standard $49.99→$83/mo) and enhanced/"stealth" proxy no longer costs +4 credits (now 1 credit, same as basic). Pricing dollar figures below reflect the annual-billing effective monthly rate; monthly-billed is higher — re-check firecrawl.dev/pricing before quoting a client.

Overview

Firecrawl is a web data API for AI. You hand it a URL and it returns the page as clean markdown, raw/processed HTML, a screenshot, a link list, or structured JSON — having already solved the parts that make DIY scraping painful: rotating proxies, anti-bot challenges, JavaScript/SPA rendering, PDFs, and dynamic content. It exposes five core primitives plus interactive browser control, all behind one API key.

For codeAmani, Firecrawl is the "web → context" lever: it's how you feed external pages into a RAG pipeline (pairs with the pinecone / pgvector guides), how an agent reads a page it was asked about, and how you pull structured facts (prices, listings, docs) off sites that have no API.

When Firecrawl wins — and when raw Playwright/Cheerio wins

flowchart TD
  A["Need data off a web page"] --> B{"Do you control the<br/>site / have an API?"}
  B -->|"yes"| C["Use the API / DB directly<br/>(don't scrape)"]
  B -->|"no"| D{"LLM-ready output?<br/>proxies + anti-bot?<br/>many sites?"}
  D -->|"yes — RAG / agents"| E["Firecrawl<br/>managed, per-credit"]
  D -->|"no — 1 static site,<br/>full DOM control, free"| F["Cheerio / Playwright<br/>self-hosted"]
  E --> G["scrape · crawl · map<br/>search · extract"]
  F --> H["You own proxies,<br/>retries, JS, parsing"]
Tool Reach for it when… Cost shape
Firecrawl RAG ingestion, agent web-browsing, scraping many sites, JS-heavy pages, you want markdown/JSON not HTML, you don't want to run proxy/anti-bot infra Per-credit (managed)
Cheerio One known static site, server-rendered HTML, you only need a few selectors, zero budget Free (your CPU)
Playwright / Puppeteer You need full programmatic browser control, custom auth flows, screenshots of your own app, and you're happy to operate proxies + anti-bot yourself Free (your infra)

Rule of thumb: Firecrawl converts the web into LLM input; Playwright/Cheerio give you a browser/parser you operate yourself. Firecrawl even offers a Browser Sandbox (managed Playwright-over-CDP) when you do need raw browser control without running the infra.

Primary use cases

Official Documentation

Resource URL
Introduction https://docs.firecrawl.dev/introduction
Node SDK https://docs.firecrawl.dev/sdks/node
Python SDK https://docs.firecrawl.dev/sdks/python
Crawl feature (async + webhooks) https://docs.firecrawl.dev/features/crawl
Parse files (PDF/DOCX/XLSX) https://docs.firecrawl.dev/features/parse
Enhanced ("stealth") proxy mode https://docs.firecrawl.dev/features/enhanced-mode
Rate limits & concurrency https://docs.firecrawl.dev/rate-limits
Webhooks & signature verification https://docs.firecrawl.dev/webhooks/overview
MCP server https://docs.firecrawl.dev/mcp-server
Open source / self-host https://docs.firecrawl.dev/contributing/open-source-or-cloud

Setup

Get an API key at firecrawl.dev/app/api-keys (keys are prefixed fc-). Both SDKs read FIRECRAWL_API_KEY from the environment automatically, or you can pass it explicitly.

Node / TypeScript

npm install firecrawl        # v4.x — the docs-recommended package name
# @mendable/firecrawl-js is the legacy scoped alias for the same 4.x releases
import { Firecrawl } from "firecrawl";

const firecrawl = new Firecrawl({ apiKey: process.env.FIRECRAWL_API_KEY });

// Single page → clean markdown
const doc = await firecrawl.scrape("https://firecrawl.dev", {
  formats: ["markdown"],
  onlyMainContent: true,
});
console.log(doc.markdown);

Python

pip install firecrawl-py
from firecrawl import Firecrawl  # AsyncFirecrawl is also exported

firecrawl = Firecrawl(api_key="fc-YOUR-API-KEY")  # or omit to read FIRECRAWL_API_KEY

doc = firecrawl.scrape("https://firecrawl.dev", formats=["markdown"], only_main_content=True)
print(doc.markdown)

Node uses camelCase option keys (onlyMainContent, scrapeOptions); Python uses snake_case (only_main_content, scrape_options). The REST API itself is camelCase.


Core Endpoints

All endpoints live under https://api.firecrawl.dev/v2/ and authenticate with Authorization: Bearer fc-....

const doc = await firecrawl.scrape("https://example.com/article", {
  formats: ["markdown", "html", "links", "screenshot"],
  onlyMainContent: true,          // strip nav/footer/ads — fewer tokens
  includeTags: ["article", "main"],
  excludeTags: ["nav", "footer", ".ad"],
  maxAge: 600000,                 // serve from cache if scraped < 10 min ago
});

Response (SDKs return the data object directly; cURL wraps it in { success, data }):

{
  "markdown": "# Article title\n\nClean body text…",
  "html": "<!DOCTYPE html>…",
  "links": ["https://example.com/next", "…"],
  "screenshot": "https://…/screenshot.png",
  "metadata": {
    "title": "Article title",
    "sourceURL": "https://example.com/article",
    "statusCode": 200,
    "scrapeId": "019eb884-…"
  }
}

Output formats: markdown, html, rawHtml, links, screenshot, summary, json, changeTracking, branding. Use onlyMainContent: true plus includeTags/excludeTags to cut boilerplate (and token count) before it ever reaches your LLM.

Actions (dynamic pages — click, scroll, wait, input)

Pass an actions array to drive a real browser before extraction. v2 action shape (verified):

const doc = await firecrawl.scrape("https://example.com", {
  actions: [
    { type: "wait", milliseconds: 1000 },
    { type: "click", selector: "#accept" },
    { type: "scroll", direction: "down" },
    { type: "click", selector: "#q" },
    { type: "write", text: "firecrawl" },   // text input
    { type: "press", key: "Enter" },
    { type: "wait", milliseconds: 2000 },
    { type: "screenshot" },
  ],
  formats: ["markdown"],
});

Action types: wait (milliseconds), click (selector), scroll (direction), write (text), press (key), screenshot. For a persistent interactive session, use interact(scrapeId, …) / stopInteraction(scrapeId) against a prior scrape's metadata.scrapeId, or the Browser Sandbox (firecrawl.browser(...) → CDP URL for full Playwright).

/map — fast sitemap / URL discovery

const res = await firecrawl.map("https://docs.firecrawl.dev", {
  search: "webhook",   // optional: rank URLs by relevance
  limit: 100,
});
console.log(res.links); // ["https://docs.firecrawl.dev/webhooks/overview", …]

map is the cheap reconnaissance step: discover the URL list first, decide what's worth scraping, then crawl/scrape only those — instead of crawling blind.

/crawl — recursive site crawl (async job + status polling)

Crawl-and-wait (handles the job + pagination for you — recommended):

const job = await firecrawl.crawl("https://docs.firecrawl.dev", {
  limit: 100,                       // default is 10,000 — always set a limit
  includePaths: ["^/features/.*"],  // regex on pathname
  excludePaths: ["^/blog/.*"],
  maxDiscoveryDepth: 3,
  sitemap: "include",               // "include" | "skip" | "only"
  scrapeOptions: { formats: ["markdown"], onlyMainContent: true },
});
console.log(job.status, job.data.length); // "completed", N docs

Start-and-poll (long crawls / custom polling):

const { id } = await firecrawl.startCrawl("https://docs.firecrawl.dev", { limit: 500 });
const status = await firecrawl.getCrawlStatus(id);
// status.status ∈ "scraping" | "completed" | "failed"; status.completed / status.total
// status.data = pages scraped so far; cancel with firecrawl.cancelCrawl(id)

Python mirrors this: firecrawl.crawl(url, limit=…, scrape_options=ScrapeOptions(...)), firecrawl.start_crawl(...), firecrawl.get_crawl_status(job.id).

Crawl gotchas (verified): default limit is 10,000 and the endpoint pre-checks that your credit balance covers it — set a real limit or you'll hit 402 Payment Required. By default crawl only follows children of the start path; use crawlEntireDomain, allowSubdomains, or allowExternalLinks to widen. Job results are retrievable via the API for 24 hours; after that, use the activity logs. data holds pages Firecrawl successfully scraped (even if the site returned 404) — fetch hard failures via the Get Crawl Errors endpoint (GET /crawl/{id}/errors).

/search — web search → scraped results

const results = await firecrawl.search("best dash cams 2026", {
  limit: 5,
  sources: ["web", "news", "images"],
  tbs: "qdr:m",                              // time filter: past month
  scrapeOptions: { formats: ["markdown"] },  // scrape each result inline
});
// results.web[] = { url, title, description, position, (markdown if scraped) }

One call searches the web and returns full page content for each hit — no separate scrape loop.

/extract — LLM structured extraction (schema → JSON)

const res = await firecrawl.extract({
  urls: ["https://example-forum.com/topic/123"],
  prompt: "Extract all user comments from this thread.",
  schema: {
    type: "object",
    properties: {
      comments: {
        type: "array",
        items: {
          type: "object",
          properties: { author: { type: "string" }, comment_text: { type: "string" } },
          required: ["author", "comment_text"],
        },
      },
    },
    required: ["comments"],
  },
});
console.log(res.data);
from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: str

data = firecrawl.extract(
    urls=["https://shop.example.com/item/42"],
    prompt="Extract the product name and price.",
    schema=Product,                 # a Pydantic model or a raw JSON Schema
    enable_web_search=True,         # optionally enrich from related pages
)
print(data.data)

Caching, proxies/stealth, PDFs & dynamic content


Developer Resources

Official SDKs

Framework integrations

MCP server (yes — first-class)

Firecrawl ships an official MCP server so Claude, Cursor, Windsurf, and VS Code can call scrape/search/crawl/etc. directly. Two ways to connect:

See docs.firecrawl.dev/mcp-server. Tools now span web (scrape/search/crawl/map/extract), page interaction, monitoring, and research-paper search. (This guide's research was done through that exact MCP server.)

Self-hosting / open source

Firecrawl is open source (github.com/firecrawl/firecrawl, AGPL-licensed) and self-hostable — run the API on your own infra for data-residency or cost control. The hosted cloud adds managed proxies, anti-bot, scale, and higher reliability; the self-host build asks you to bring your own proxy/anti-bot. Decision guide: docs.firecrawl.dev/contributing/open-source-or-cloud.

Rate limits & concurrency (verified 2026-08-23)

Two independent limits; exceeding either returns 429:

API rate limits (requests/min, current plans):

Plan /scrape /map /crawl /search
Free 10 10 2 10
Hobby 100 100 20 100
Standard 500 500 100 500
Growth 5,000 5,000 1,000 5,000
Scale 10,000 10,000 2,000 10,000

Concurrent browsers (parallel jobs ceiling): Free 2, Hobby 5, Standard 50, Growth 100, Scale/Enterprise 150+. Max queued jobs scale with the plan (Free/Hobby 50k, Standard 100k, Growth 200k, Scale 300k+). Jobs beyond the concurrency ceiling queue (and queue time counts against the request timeout). Check live headroom with the Queue Status endpoint. (Note: the pricing page also advertises a lower per-plan "concurrent requests" figure — Standard 25 / Growth 50 / Scale 100 — which is a distinct metric from the concurrent-browser ceilings above.)

Firecrawl's own guidance: rate limits exist mainly to prevent abuse — your real bottleneck is concurrent browsers, so size the plan by concurrency, not req/min.

Retry / backoff

The SDKs auto-retry and handle async polling. For your own loops, treat 429 and 5xx as retryable with exponential backoff + jitter; for 429, prefer reducing concurrency over hammering. 402 means out of credits (raise limit awareness or enable auto-recharge), not a transient error.

Webhooks for async crawl completion (verified)

Attach a webhook object to a crawl to get pushed events instead of polling:

// POST https://api.firecrawl.dev/v2/crawl
{
  "url": "https://docs.firecrawl.dev",
  "limit": 100,
  "webhook": {
    "url": "https://your-domain.com/api/webhooks/firecrawl",
    "metadata": { "tenant": "acme" },
    "events": ["started", "page", "completed"]
  }
}

Event types: crawl.started, crawl.page, crawl.completed, crawl.failed. Every request carries an X-Firecrawl-Signature header (sha256=…) — an HMAC-SHA256 of the raw body using your webhook secret (from the dashboard Advanced tab). Verify it with a timing-safe compare before processing — see codeAmani notes below.


Pricing (verify live before quoting)

These change — re-check firecrawl.dev/pricing. Dollar figures below are the annual-billing effective monthly rate shown on the pricing page on 2026-08-23; monthly-billed is higher. Verified live. Prices rose since the June review (Standard $49.99→$83, Growth $149.99→$333).

Plan Price (annual, eff. /mo) Credits / mo Concurrent browsers
Free $0 (no card) 1,000 2
Hobby $16 5,000 5
Standard (recommended) $83 100,000 50
Growth $333 500,000 100
Scale $599 1,000,000 150
Enterprise Custom Custom Custom (SSO, ZDR, SLA)

Credit-per-action model (verified)

Action Credit cost
Scrape 1 / page
Crawl 1 / page
Map 1 / page
Search 2 / 10 results
Interact 2 / browser-minute
Monitor 1 / page / check
Enhanced/"stealth" proxy 1 / page (no surcharge — changed 2026)
JSON mode (LLM structured extraction on a page) +4 / page
PII redaction · audio/video extraction · question/highlights format +4 / page (each)
Zero-Data-Retention (ZDR) +1 / page
PDF parsing 1 / PDF page

So a plain scrape/crawl page — even with the anti-bot enhanced proxy — is 1 credit. The LLM add-ons are what cost: turning on structured JSON (or PII redaction / A-V extraction / question/highlights) roughly 5×'s the per-page cost. Enhanced proxy is no longer one of those multipliers. Budget for the LLM formats, not the proxy.

Overage behavior

No pure pay-as-you-go. Auto-recharge can auto-purchase additional credit packs when you dip below a threshold (larger packs = better rate). Credits do not roll over month-to-month; credit packs have their own billing periods. Downgrades take effect at the next renewal.


Usage Monitoring


codeAmani Notes


Troubleshooting

Issue Fix
401 Unauthorized Check FIRECRAWL_API_KEY (must start with fc-); confirm it's read server-side
402 Payment Required on crawl Credit balance can't cover limit — lower limit or enable auto-recharge
429 Too Many Requests Hitting rate or concurrency limit — back off with jitter; reduce concurrency or upgrade plan
Empty / nav-only markdown JS-rendered SPA — add waitFor: 5000, or actions to trigger content; try map to find the real content URL
Old crawlUrl() / scrapeUrl() errors Those are v1 SDK method names (SDK ≤1.x) — upgrade to the v4.x SDK and use scrape, crawl, getCrawlStatus. Either package name works (firecrawl or the still-maintained @mendable/firecrawl-js); the package isn't the problem, the method name is
Crawl missing sibling/parent pages Crawl follows children by default — set crawlEntireDomain / allowSubdomains
Crawl results gone after a day API retains job results for 24h; pull from activity logs after that
Surprise high credit bill JSON mode (and PII redaction / A-V / question / highlights) add +4 credits/page; PDFs bill per page — audit metadata.creditsUsed. (Enhanced proxy is not a multiplier any more — it's 1 credit.)

Verification Status (2026-08-23)

Live-verified against docs.firecrawl.dev (via Firecrawl's own scrape API + Context7 + npm/PyPI registries):

Official docs:

Geolocation Integration Guide

Technology: geolocation · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/geolocation

Insight:

Geolocation is a built-in browser API, not a package — the work is all in how you ask (explicit, revocable consent), when you stop (clearWatch or you leak battery), and what you may store (every lat/lng is KDPA-2019 personal data). The core trade-off: the device API is GPS-precise but consent- and HTTPS-gated, while IP geolocation is silent and free but only city-accurate (and wrong behind Kenyan carrier NAT/VPNs). codeAmani reaches for the precise signal only where a feature needs it — the M-Pesa agent locator, rider tracking — and defaults from IP before the user opts in.

 ██████╗ ███████╗ ██████╗ ██╗      ██████╗  ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗   ██╗
██╔════╝ ██╔════╝██╔═══██╗██║     ██╔═══██╗██╔════╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗  ██║
██║  ███╗█████╗  ██║   ██║██║     ██║   ██║██║     ███████║   ██║   ██║██║   ██║██╔██╗ ██║
██║   ██║██╔══╝  ██║   ██║██║     ██║   ██║██║     ██╔══██║   ██║   ██║██║   ██║██║╚██╗██║
╚██████╔╝███████╗╚██████╔╝███████╗╚██████╔╝╚██████╗██║  ██║   ██║   ██║╚██████╔╝██║ ╚████║
 ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝  ╚═════╝╚═╝  ╚═╝   ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

Geolocation Integration Guide

Focus: get a device's position from the browser with explicit, revocable consent — ask for permission, read it once or watch it over time, always clean up the watcher, and treat every latitude/longitude as KDPA-regulated personal data. IP geolocation is the coarse, consent-free fallback.

Overview

Location is a built-in web API, not a package. navigator.geolocation ships in every modern browser, so for the on-device case there is nothing to npm install — the work is all in how you ask, when you stop, and what you are allowed to store.

There are two fundamentally different ways to know where a user is, and they sit at opposite ends of the accuracy/consent spectrum:

  1. Device geolocation (navigator.geolocation) — GPS, Wi-Fi, and cell triangulation, accurate to a few metres outdoors. It is gated behind explicit user consent and only works in a secure context (HTTPS). This is what you use for a rider's live position or "find my location" on a map.
  2. IP-based geolocation — derive an approximate city/region from the request IP, server-side, with no prompt and no GPS. Accurate only to city level (and wrong behind VPNs/carrier NAT — common on Kenyan mobile networks). This is your coarse fallback for defaulting a country/currency before the user opts in.

The whole design tension is: the browser API is precise but requires consent and battery; the IP fallback is free and silent but coarse. A good product asks for the precise signal only when the feature genuinely needs it, and degrades gracefully to the coarse one when permission is denied.

Three hard truths shape every integration:

Official Documentation

Source URL What it covers
MDN Geolocation API https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API getCurrentPosition, PositionOptions, error codes, HTTPS requirement
MDN watchPosition https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition Watcher signature, clearWatch, React cleanup
MDN Permissions API https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API navigator.permissions.query, state values, change event
W3C Geolocation spec https://www.w3.org/TR/geolocation/ The normative model, coordinate fields, security/privacy
web.dev — user location https://web.dev/articles/user-location Consent UX patterns, accuracy/battery trade-offs
Google Maps Geocoding https://developers.google.com/maps/documentation/geocoding/overview Reverse geocoding lat/lng → address

The shape of a position

A success callback receives a GeolocationPosition: a coords object plus a timestamp.

coords field Meaning Notes
latitude / longitude WGS84 decimal degrees The PII. Treat as regulated.
accuracy Radius of confidence, metres Always present. ~5–20 m with GPS, hundreds with Wi-Fi/IP.
altitude / altitudeAccuracy Metres above sea level null on most phones.
heading Degrees from true north (0–360) null when stationary.
speed Metres/second null unless moving.

Errors arrive as a GeolocationPositionError with a numeric code:

Code Constant Meaning Your move
1 PERMISSION_DENIED User said no (or revoked) Fall back to IP / manual entry
2 POSITION_UNAVAILABLE No fix available Retry or fall back
3 TIMEOUT Didn't resolve within timeout Retry with a longer timeout

PositionOptions — the three knobs

interface PositionOptions {
  enableHighAccuracy?: boolean; // default false — true asks for GPS (slower, more battery)
  timeout?: number;             // default Infinity (ms) — max wait for a fix
  maximumAge?: number;          // default 0 (ms) — accept a cached fix up to this old
}

enableHighAccuracy: false + a non-zero maximumAge is the cheap, battery-friendly default; enableHighAccuracy: true + maximumAge: 0 is the precise, expensive one. Choose per feature, not globally.


Pattern 1 — check permission, then getCurrentPosition

Query the Permissions API first so you can tailor UX (don't slam an unprompted prompt on page load — explain why you need location, then trigger it from a user gesture).

// lib/geolocation.ts
export type GeoState = "granted" | "denied" | "prompt" | "unsupported";

export async function getGeoPermission(): Promise<GeoState> {
  if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
    return "unsupported";
  }
  // Permissions API isn't in every browser; degrade to "prompt".
  if (!("permissions" in navigator)) return "prompt";
  try {
    const status = await navigator.permissions.query({ name: "geolocation" });
    return status.state; // "granted" | "denied" | "prompt"
  } catch {
    return "prompt";
  }
}

/** Promise wrapper around the callback-based getCurrentPosition. */
export function getCurrentPosition(
  options: PositionOptions = { enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 },
): Promise<GeolocationPosition> {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, options);
  });
}
// usage — trigger from a click, never on mount
async function locateMe() {
  if ((await getGeoPermission()) === "denied") {
    // Browser won't re-prompt once denied — guide the user to site settings,
    // or fall back to coarse IP lookup / manual address entry.
    return useIpFallback();
  }
  try {
    const pos = await getCurrentPosition();
    const { latitude, longitude, accuracy } = pos.coords;
    // ... use lat/lng. accuracy (m) tells you how much to trust it.
  } catch (err) {
    const code = (err as GeolocationPositionError).code;
    if (code === 1) return useIpFallback();      // PERMISSION_DENIED
    if (code === 3) return getCurrentPosition({ timeout: 20_000 }); // TIMEOUT → retry
    return useIpFallback();                       // POSITION_UNAVAILABLE
  }
}
flowchart TD
    A["Feature needs location"] --> B{"navigator.geolocation exists?"}
    B -->|"no"| F["IP fallback / manual entry"]
    B -->|"yes"| C["permissions.query(geolocation)"]
    C --> D{"state?"}
    D -->|"granted"| G["getCurrentPosition · use fix"]
    D -->|"prompt"| E["Show rationale · user gesture triggers prompt"]
    D -->|"denied"| F
    E --> H{"User choice"}
    H -->|"allow"| G
    H -->|"block"| F

Pattern 2 — watchPosition with React cleanup

For live tracking (a rider en route, a delivery on a map), watchPosition registers a handler that fires only when the position changes. It returns a numeric watch id you must pass to clearWatch — in React, in the effect's cleanup function.

"use client";
import { useEffect, useState } from "react";

type Fix = { lat: number; lng: number; accuracy: number; at: number };

export function useWatchPosition(active: boolean) {
  const [fix, setFix] = useState<Fix | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!active || typeof navigator === "undefined" || !("geolocation" in navigator)) {
      return;
    }

    const watchId = navigator.geolocation.watchPosition(
      (pos) => {
        const c = pos.coords;
        setFix({ lat: c.latitude, lng: c.longitude, accuracy: c.accuracy, at: pos.timestamp });
        setError(null);
      },
      (err) => setError(`(${err.code}) ${err.message}`),
      { enableHighAccuracy: true, timeout: 15_000, maximumAge: 5_000 },
    );

    // CRITICAL: stop the watcher → releases the GPS radio, saves battery.
    return () => navigator.geolocation.clearWatch(watchId);
  }, [active]);

  return { fix, error };
}
sequenceDiagram
    participant C as Component (mount)
    participant G as navigator.geolocation
    participant D as Device GPS/radio
    C->>G: watchPosition(success, error, opts)
    G-->>C: watchId
    D->>G: position changed
    G->>C: success(GeolocationPosition)
    D->>G: position changed again
    G->>C: success(...)
    Note over C,G: component unmounts
    C->>G: clearWatch(watchId)
    G->>D: release radio · stop polling

Foreground only. The web Geolocation API does not run in the background — once the tab is backgrounded or closed, updates stop. There is no web equivalent of a native background-location service. For genuine background rider tracking you need a native/PWA approach or periodic foreground check-ins; don't promise continuous tracking the web can't deliver.


Accuracy & battery trade-offs

Goal enableHighAccuracy maximumAge Source Cost
"Roughly where are they" (default country/branch) false large (e.g. 5 min) Wi-Fi/cell/cache cheap
"Pin on a map, one-off" true 0 GPS one GPS wake
"Live route tracking" true small (e.g. 5 s) GPS continuous battery-heavy

Rules of thumb: request high accuracy only when the UI actually plots a precise point; let maximumAge serve a recent cached fix instead of waking the GPS; stop watchers the instant the screen is no longer visible. On the 2G/3G-and-budget-Android reality of the Kenyan market, an always-on high-accuracy watcher will drain a rider's phone before lunch — gate it behind "I'm on a delivery" state.

Geofencing (concept)

The web has no native geofence API (watchPosition won't wake your code when the app is closed). You approximate it in the foreground: keep a target point + radius, and on each watchPosition update compute the great-circle (haversine) distance; when it crosses the radius, fire your event ("rider arrived at the customer", "near the M-Pesa agent").

/** Haversine distance in metres between two lat/lng points. */
export function distanceMeters(a: [number, number], b: [number, number]): number {
  const R = 6_371_000; // earth radius (m)
  const toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(b[0] - a[0]);
  const dLng = toRad(b[1] - a[1]);
  const lat1 = toRad(a[0]);
  const lat2 = toRad(b[0]);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}
// inside watchPosition success: if (distanceMeters(here, agent) < 50) flagArrived();

For true server-side / background geofencing you push raw points to your backend and evaluate the geometry there (PostGIS ST_DWithin, or a managed service).

Reverse geocoding

Coordinates are not human-readable — "−1.2921, 36.8219" means nothing to a customer; "Kenyatta Avenue, Nairobi" does. Reverse geocoding turns lat/lng into an address, and it's a server-side call to a provider (so your API key never ships to the browser):

Cache results (coordinates rarely move much) and call from a route handler, not the client. Forward geocoding (address → lat/lng) uses the same providers for "enter your delivery address".


Location is personal data under Kenya's Data Protection Act, 2019 (KDPA), and precise location is among the most sensitive categories you can hold — it reveals home, workplace, and movement patterns. Treat every stored lat/lng accordingly.

// React to revocation mid-session
const status = await navigator.permissions.query({ name: "geolocation" });
status.addEventListener("change", () => {
  if (status.state === "denied") stopAllTracking(); // clearWatch + stop persisting
});

IP-based geolocation (coarse fallback)

When consent is denied/unsupported, or you just want a sensible default before asking, resolve an approximate location from the request IP — server-side, no prompt:

// app/api/locate/route.ts — Next.js on Vercel edge
export function GET(req: Request) {
  const country = req.headers.get("x-vercel-ip-country") ?? "KE";
  const city = req.headers.get("x-vercel-ip-city") ?? null;
  return Response.json({ country, city, source: "ip", precise: false });
}

codeAmani notes

Official docs:

GitHub Developer Course

Technology: github · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/github

Insight:

GitHub is where code lives, ships, and gets reviewed. This is a course, not just a reference: Git fundamentals → collaboration (PRs/reviews) → automation (Actions/CI) → security → ecosystem → Claude Code. The two ideas that unlock everything: branches are cheap pointers to commits (so merge/rebase/squash are just different ways to reshape history), and a PR is a proposal gated by review + CI before it touches master. For codeAmani, a push to master is the deploy trigger — so branch protection + CI are what keep production safe. Never commit secrets; pair secret scanning + push protection with Infisical's scan in CI.

 ██████╗ ██╗████████╗██╗  ██╗██╗   ██╗██████╗
██╔════╝ ██║╚══██╔══╝██║  ██║██║   ██║██╔══██╗
██║  ███╗██║   ██║   ███████║██║   ██║██████╔╝
██║   ██║██║   ██║   ██╔══██║██║   ██║██╔══██╗
╚██████╔╝██║   ██║   ██║  ██║╚██████╔╝██████╔╝
 ╚═════╝ ╚═╝   ╚═╝   ╚═╝  ╚═╝ ╚═════╝ ╚═════╝

GitHub Developer Course

Focus: A hands-on path from git init to shipping with confidence — Git fundamentals, collaboration on GitHub, Actions/CI-CD, security, the wider ecosystem, and driving it all from Claude Code. Built to enhance your capabilities, not just list commands. Grounded in docs.github.com; reviewed 2026-08-23.

How this course works

Three levels, each ending with a ✅ capability checkpoint ("you can now…") and a 🛠 exercise. Work top-to-bottom the first time; use the command cheat-sheet and Table of Contents as reference after. The interactive learn module above this page — a branch & PR lifecycle simulator — is your illustration for Level 2; play with it before reading the merge/rebase section.

Table of Contents

Official Documentation

Resource URL
GitHub Docs (root) https://docs.github.com
Get started https://docs.github.com/en/get-started
GitHub Actions https://docs.github.com/en/actions
Code security https://docs.github.com/en/code-security
gh CLI manual https://cli.github.com/manual
REST API https://docs.github.com/en/rest
GraphQL API https://docs.github.com/en/graphql
GitHub MCP server https://github.com/github/github-mcp-server

Level 1 — Git fundamentals

Git vs GitHub

Git is the version-control tool that runs on your machine — it records snapshots (commits) of your files. GitHub is the hosted home for Git repositories that adds collaboration: pull requests, reviews, issues, CI/CD, and security.

   your machine (Git)                         GitHub (remote)
 ┌────────────────────┐    git push     ┌────────────────────┐
 │ working dir        │ ─────────────▶  │  origin/master     │
 │   └ staging (index)│                 │  PRs · Actions ·    │
 │       └ .git/ (commits, branches)    │  Issues · Security  │
 └────────────────────┘ ◀───────────── └────────────────────┘
                          git pull

A file moves through three states: working directory → staging area (git add) → committed history (git commit).

Setup & first-time config

# Install Git + the GitHub CLI
winget install Git.Git GitHub.cli     # Windows
brew install git gh                    # macOS
sudo apt install git gh               # Debian/Ubuntu (incl. WSL)

# One-time identity
git config --global user.name  "Your Name"
git config --global user.email "you@codeamani.com"
git config --global init.defaultBranch main
git config --global pull.rebase false   # merge on pull (or 'true' to rebase)

# Authenticate the CLI (browser flow; stores a token securely)
gh auth login

The core loop

git init                      # start a repo here   (or: gh repo clone owner/name)
git status                    # what changed?
git add file.ts               # stage a change      (git add -A for everything)
git commit -m "feat: add login form"
git log --oneline --graph     # see history as a graph
git diff                      # unstaged changes    (git diff --staged for staged)

Write good commit messages — a short imperative summary (fix: handle null token), optionally a body explaining why. The codeAmani convention follows Conventional Commits (feat:, fix:, chore:, docs:).

Branches: the cheap pointer

A branch is just a movable pointer to a commit — creating one is instant and free. This is the mental model that makes everything else click.

git switch -c feature/mpesa-stk      # create + switch (old: git checkout -b)
git switch master                    # switch back
git branch                           # list local branches
git branch -d feature/mpesa-stk      # delete a merged branch

Remotes & pushing to GitHub

gh repo create codeAmani/my-app --private --source=. --push   # create + link + push
# …or link an existing remote
git remote add origin https://github.com/codeAmani/my-app.git
git push -u origin master            # -u sets the upstream once
git pull                             # fetch + merge remote changes
git fetch origin                     # download without merging
# .gitignore — never track secrets or build junk
.env*
node_modules/
.next/
*.log

✅ Checkpoint: You can initialise a repo, stage and commit changes, branch, and push to GitHub. 🛠 Exercise: Create a repo with gh repo create, add a README.md, commit on a feature/readme branch, and push it.


Level 2 — Collaboration

Merge vs rebase vs squash

The three ways to integrate a branch — the concept developers most often get wrong. Play with the simulator above to see the commit graph redraw for each. Here's a feature branch being merged back, drawn as a real commit graph:

gitGraph
   commit id: "A"
   commit id: "B"
   branch feature
   checkout feature
   commit id: "D"
   commit id: "E"
   checkout main
   commit id: "C"
   merge feature
Strategy What it does History Use when
Merge Creates a merge commit joining both lines Preserves true branch shape Shared/long-lived branches; you want the full record
Rebase Replays your commits on top of the target Linear, no merge commits Cleaning up your local branch before a PR
Squash Combines all branch commits into one One tidy commit per feature Merging a PR into master (the codeAmani default)
git merge feature/x                  # merge feature/x into current branch
git rebase master                    # replay current branch on top of master
git rebase -i HEAD~3                  # interactively squash/reorder last 3 commits
# Golden rule: never rebase commits you've already pushed to a shared branch.
merge:   A───B───C (master)          rebase:  A───B───C───D'──E' (master)
              \                                (D,E replayed cleanly on top)
               D───E (feature) ──▶ merge commit M

Pull requests: the unit of collaboration

A PR proposes merging one branch into another, gated by review + CI before it lands. Lifecycle: open → review → CI checks → approve → merge.

gh pr create --title "feat: M-Pesa STK push" --body "Implements Daraja STK flow"
gh pr create --fill                  # use branch name + last commit as title/body
gh pr list                           # open PRs
gh pr view 42 --web                  # open in browser
gh pr checks 42                      # CI status for the PR
gh pr merge 42 --squash --delete-branch

Code review

gh pr diff 42                        # read the changes
gh pr review 42 --approve
gh pr review 42 --request-changes --body "Validate the phone format (2547…)"
gh pr review 42 --comment --body "Nice — one nit inline"

A good review checks: correctness, security (no secrets, input validated), tests, and clarity. Keep PRs small — they get reviewed faster and merge cleaner.

Resolving conflicts

git switch feature/x
git merge master                     # conflict markers appear in files
# Edit the <<<<<<< / ======= / >>>>>>> sections, choosing the right code, then:
git add resolved-file.ts
git commit                           # completes the merge

Undoing things safely

Goal Command Safe on shared history?
Discard unstaged file change git restore file ✅
Unstage a file git restore --staged file ✅
Amend the last commit git commit --amend ❌ (rewrites)
Undo a commit, keep changes git reset --soft HEAD~1 ❌
Revert a pushed commit git revert <sha> ✅ (new inverse commit)
Stash work-in-progress git stash / git stash pop ✅
Recover "lost" commits git reflog ✅ (your safety net)

git revert is the safe public undo; git reset/--amend rewrite history (force-push territory). When in doubt, git reflog remembers where everything was.

Issues, labels & Projects

gh issue create --title "Bug: STK callback times out" --label bug,priority:high
gh issue list --assignee @me
gh issue close 17 --comment "Fixed in #42"
# Projects (v2) — track work on a board; manage via the GraphQL API or the UI
gh project list --owner codeAmani

Branch protection

Protect master so nothing merges unreviewed or red. Set via Settings → Branches or the API:

gh api -X PUT repos/codeAmani/my-app/branches/master/protection \
  -F required_pull_request_reviews.required_approving_review_count=1 \
  -F required_status_checks.strict=true \
  -F enforce_admins=true

✅ Checkpoint: You can open a PR, review it, resolve conflicts, undo mistakes safely, and protect a branch. 🛠 Exercise: Open a PR from your feature/readme branch, request a change on it, push a fix, then squash-merge it.


Level 3 — Automation & ecosystem

GitHub Actions (CI/CD)

Workflows are YAML in .github/workflows/. Structure: events (on) → jobs → steps. Jobs run in parallel unless chained with needs.

flowchart LR
  PR["push / pull_request"] --> T["job: test<br/>matrix 22, 24"]
  T --> D["job: deploy<br/>needs: test"]
  D --> V["Vercel auto-deploy"]
# .github/workflows/ci.yml
name: CI
on:
  push: { branches: [master] }
  pull_request: { branches: [master] }
  workflow_dispatch:           # manual "Run workflow" button

permissions:
  contents: read               # least privilege by default

concurrency:                   # cancel superseded runs on the same ref
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [22, 24]         # run across versions in parallel (24 = Active LTS)
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: ${{ matrix.node }}, cache: npm }
      - run: npm ci
      - run: npm test

  deploy:
    needs: test                # only after test passes
    if: github.ref == 'refs/heads/master'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: echo "Deploy step (Vercel auto-deploys on push for codeAmani)"

Key building blocks:

gh workflow list
gh workflow run ci.yml --field environment=production
gh run watch                   # live-stream the active run
gh run view 12345678 --log

Packages & Releases

# Releases (auto-generate notes from merged PRs)
gh release create v1.2.0 --generate-notes
gh release list
# Packages — publish to GitHub Packages (npm/Container/etc.)
# npm: set "publishConfig": { "registry": "https://npm.pkg.github.com" } then `npm publish`

Security (shift left)

GitHub's built-in developer security suite (enable under Settings → Code security):

Feature What it does
Dependabot alerts Flags dependencies with known vulnerabilities
Dependabot security updates Auto-opens PRs to patch vulnerable deps
Dependabot version updates Auto-opens PRs to keep deps current (dependabot.yml)
Secret scanning Detects hardcoded credentials committed to the repo
Push protection Blocks a push if it contains a detected secret
Code scanning (CodeQL) Static analysis for vulns/bugs in new or changed code
Dependency graph Maps what your repo depends on (and what depends on it)
Security advisories Privately discuss + fix, then publish a vulnerability alert
# .github/dependabot.yml — keep npm deps current weekly
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule: { interval: "weekly" }
# .github/workflows/codeql.yml — CodeQL code scanning
name: CodeQL
on: { push: { branches: [master] }, pull_request: { branches: [master] } }
jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions: { security-events: write, contents: read }
    steps:
      - uses: actions/checkout@v7
      - uses: github/codeql-action/init@v4
        with: { languages: javascript-typescript }
      - uses: github/codeql-action/analyze@v4

Codespaces

A cloud dev environment (a container in the browser or VS Code) defined by a devcontainer.json — instant, reproducible onboarding.

// .devcontainer/devcontainer.json
{
  "image": "mcr.microsoft.com/devcontainers/javascript-node:24",
  "features": { "ghcr.io/devcontainers/features/github-cli:1": {} },
  "postCreateCommand": "npm install",
  "customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint"] } }
}
gh codespace create -r codeAmani/my-app
gh codespace code            # open in VS Code

GitHub Pages

Free static hosting from a repo (great for docs/landing pages). Enable under Settings → Pages, or deploy via Actions:

# publishes ./dist to Pages
permissions: { pages: write, id-token: write }
# … build, then:
- uses: actions/upload-pages-artifact@v5
  with: { path: dist }
- uses: actions/deploy-pages@v5

gh CLI & API mastery

# REST — anything the API exposes
gh api repos/codeAmani/my-app/pulls --jq '.[].title'
gh api -X POST repos/codeAmani/my-app/issues -f title="From the CLI" -f body="…"
# GraphQL — precise, fewer round-trips
gh api graphql -f query='query { viewer { login } }'
# Aliases + JSON output power scripting
gh pr list --json number,title,author --jq '.[] | "\(.number) \(.title)"'
# Pin the REST API version for stable scripts (current default: 2026-03-10)
gh api -H "X-GitHub-Api-Version: 2026-03-10" repos/codeAmani/my-app

The REST API is date-versioned: send X-GitHub-Api-Version: 2026-03-10 (the current version) to lock behavior; the legacy 2022-11-28 stays supported until March 2028. gh targets the current version by default. GraphQL is a single evolving schema (no date version) — watch the schema changelog for deprecations.

Tokens & scopes

Token type Use it for
Fine-grained PAT Preferred — per-repo, least-privilege, expiring
Classic PAT Legacy; broad scopes (repo, workflow, read:org)
GITHUB_TOKEN (Actions) Auto-injected, scoped per-workflow via permissions:
OIDC Keyless cloud auth from Actions — no stored secrets
export GITHUB_TOKEN=github_pat_...   # gh + MCP read this (GH_TOKEN also works)
# Generate at: https://github.com/settings/tokens

✅ Checkpoint: You can write a CI workflow with matrix + job dependencies, enable Dependabot + CodeQL + push protection, spin up a Codespace, and script the API. 🛠 Exercise: Add a ci.yml that runs npm test on every PR, then turn on secret scanning + push protection for the repo.


Level 4 — GitHub + Claude Code

The GitHub MCP server

The official github/github-mcp-server lets Claude open PRs, comment on issues, trigger workflows, and read security alerts in-session.

claude mcp add -s user --transport http github \
  https://api.githubcopilot.com/mcp/ \
  -H "Authorization: Bearer ${GITHUB_TOKEN}"
// .mcp.json
{
  "mcpServers": {
    "github": {
      "type": "http",
      "url": "https://api.githubcopilot.com/mcp/",
      "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
    }
  }
}

Docker transport (full toolset control)

claude mcp add github -- docker run -i --rm \
  -e GITHUB_PERSONAL_ACCESS_TOKEN \
  -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" \
  ghcr.io/github/github-mcp-server
Toolset Tools
repos create, read files, commit, push
issues create, comment, label, close
pull_requests create, review, merge, comment
actions list + trigger workflows
code_security read Dependabot + code-scanning alerts

The npm package @modelcontextprotocol/server-github is deprecated (April 2025). Use HTTP or the Docker image above.

Claude Code automations

<!-- .claude/commands/review-pr.md -->
Review pull request #$ARGUMENTS.
1. Use the GitHub MCP to fetch the PR diff + description and existing comments.
2. Analyse for bugs, security issues, missing tests, and convention drift.
3. Post a review: approve if safe, else request changes with specific line notes.

Usage: /project:review-pr 42

// scripts/auto-pr.js — open a PR after a feature branch is pushed (Stop hook)
import { execFileSync } from "child_process";       // execFileSync = no shell injection
const branch = execFileSync("git", ["branch", "--show-current"]).toString().trim();
if (branch === "main" || branch === "master") process.exit(0);
if (execFileSync("git", ["status", "--porcelain"]).toString()) process.exit(0);
try { execFileSync("gh", ["pr", "create", "--fill"], { stdio: "inherit" }); } catch {}

Claude Code in CI

# .github/workflows/ai-fix.yml — headless Claude fixes lint on new PRs
name: AI Auto-fix
on: { pull_request: { types: [opened] } }
jobs:
  fix:
    runs-on: ubuntu-latest
    permissions: { contents: write, pull-requests: write }
    steps:
      - uses: actions/checkout@v7
        with: { ref: ${{ github.head_ref }} }
      - uses: actions/setup-node@v7
        with: { node-version: '24' }
      - run: npm install -g @anthropic-ai/claude-code
      - env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: claude -p "Fix all ESLint errors" --allowedTools "Bash,Edit,Read,Glob,Grep" --output-format text
      - run: |
          git config user.name "Claude Code Bot"; git config user.email "noreply@github.com"
          git add -A && git diff --staged --quiet || git commit -m "fix: auto-fix ESLint errors"
          git push

Command cheat-sheet

# Daily loop
git status / git add -A / git commit -m "…" / git push
git switch -c feature/x      # branch        git switch master
git pull --rebase            # update cleanly  git stash / git stash pop
# History
git log --oneline --graph --all
git revert <sha>             # safe undo of a pushed commit
git reflog                   # find lost commits
# GitHub via gh
gh pr create --fill / gh pr checks 42 / gh pr merge 42 --squash --delete-branch
gh issue create / gh run watch / gh release create vX --generate-notes
gh api repos/OWNER/REPO/... # raw REST/GraphQL

Troubleshooting

Issue Fix
gh: command not found Install via winget/brew/apt
401 on MCP HTTP Regenerate PAT; ensure repo scope (or correct fine-grained perms)
Push rejected (non-fast-forward) git pull --rebase then push
Push blocked by push protection A secret was detected — remove it, rotate it, recommit
Merge conflict Edit markers, git add, git commit (or git merge --abort)
Rebase went wrong git rebase --abort, or recover via git reflog
Workflow didn't run Check the on: triggers + branch/path filters
Accidentally committed a secret Rotate it immediately; history rewrite (git filter-repo) + force-push

codeAmani notes

Official docs:

Google AI Studio (Gemini API) Integration Guide

Technology: google-ai-studio · Category: ai · Last reviewed: 2026-09-01

Source: https://tech-stack.codeamanilabs.org/guide/google-ai-studio

Insight:

AI Studio is where codeAmani mints its Gemini key — the image/multimodal provider that generates this dashboard's thumbnails. It's the Developer-API path (distinct from Vertex AI), and a Maps key will NOT work here. Keep the key server-side and cache generated images in R2, since image generation is billed per image.

 ██████╗  ██████╗  ██████╗  ██████╗ ██╗     ███████╗     █████╗ ██╗
██╔════╝ ██╔═══██╗██╔═══██╗██╔════╝ ██║     ██╔════╝    ██╔══██╗██║
██║  ███╗██║   ██║██║   ██║██║  ███╗██║     █████╗      ███████║██║
██║   ██║██║   ██║██║   ██║██║   ██║██║     ██╔══╝      ██╔══██║██║
╚██████╔╝╚██████╔╝╚██████╔╝╚██████╔╝███████╗███████╗    ██║  ██║██║
 ╚═════╝  ╚═════╝  ╚═════╝  ╚═════╝ ╚══════╝╚══════╝    ╚═╝  ╚═╝╚═╝

███████╗████████╗██╗   ██╗██████╗ ██╗ ██████╗
██╔════╝╚══██╔══╝██║   ██║██╔══██╗██║██╔═══██╗
███████╗   ██║   ██║   ██║██║  ██║██║██║   ██║
╚════██║   ██║   ██║   ██║██║  ██║██║██║   ██║
███████║   ██║   ╚██████╔╝██████╔╝██║╚██████╔╝
╚══════╝   ╚═╝    ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝

Google AI Studio (Gemini API) Integration Guide

Focus: Google AI Studio is where codeAmani Labs creates its Gemini API key. The Gemini API powers text, multimodal, and image generation (it generates the dashboard's tech-stack thumbnails). This is the AI Studio / Developer-API path — distinct from Vertex AI.

Overview

Here's the high-level path your call takes — once you picture it, the rest of the guide clicks into place.

flowchart LR
  A["AI Studio<br/>create API key"] --> B["GEMINI_API_KEY<br/>server-side env"]
  B --> C["@google/genai SDK<br/>or REST"]
  C --> D["generativelanguage<br/>.googleapis.com"]
  D --> E["Gemini response<br/>text or image bytes"]
  E --> F["Cache image in R2<br/>tech-stack-bucket"]

Google AI Studio issues a Gemini Developer API key that authenticates calls to generativelanguage.googleapis.com. The official, current SDK is @google/genai (JS/TS, 2.20.0 as of 2026-09-01, needs Node 20+) and google-genai (Python, 2.21.0, needs Python 3.10+). The older @google/generative-ai package is legacy/deprecated (last release 0.24.1, Apr 2025) and no longer receives new Gemini features — do not use it for new code.

Gemini 3 is the current model generation (as of 2026-09-01); the Gemini 2.5 line is the previous generation and still available. Flash IDs iterate quickly (gemini-3.5-flash → 3.6 → 3.7-flash), so pin a dated ID for production or use the gemini-flash-latest alias to auto-track the newest release.

Official Documentation

Resource URL
Gemini API docs https://ai.google.dev/gemini-api/docs
Gemini 3 developer guide https://ai.google.dev/gemini-api/docs/gemini-3
Get an API key https://ai.google.dev/gemini-api/docs/api-key
Google AI Studio https://aistudio.google.com
JS SDK (@google/genai) https://googleapis.github.io/js-genai/
Python SDK (google-genai) https://googleapis.github.io/python-genai/
Image generation https://ai.google.dev/gemini-api/docs/image-generation
Rate limits & tiers https://ai.google.dev/gemini-api/docs/rate-limits
Error codes / troubleshooting https://ai.google.dev/gemini-api/docs/troubleshooting

1. Get an API key

  1. Go to Google AI Studio and sign in.
  2. Select Get API key → Create API key (in a Google Cloud project).
  3. Store it as GEMINI_API_KEY (the SDK also reads GOOGLE_API_KEY).
    • codeAmani convention: .env.local (gitignored) for local dev + the Vercel project's env vars for deploys. Server-side only — never ship the key to the browser.

A Maps Platform API key is NOT a Gemini key: calling the Gemini API with one returns 403 API_KEY_SERVICE_BLOCKED. Use a key created in AI Studio.

2. Install the SDK

npm install @google/genai      # JS/TS
pip install google-genai       # Python

3. Generate text

import { GoogleGenAI } from "@google/genai";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const res = await ai.models.generateContent({
  model: "gemini-3.7-flash",
  contents: "Explain M-Pesa STK Push in one sentence.",
});
console.log(res.text);

4. Generate images

Image generation now runs entirely through the Gemini "Nano Banana" image models via generateContent — the dedicated Imagen models (imagen-4.0-generate-001 / -ultra- / -fast-) and the old ai.models.generateImages path were shut down on 2026-08-17. Pick the tier by quality vs cost.

flowchart TD
  A["Image prompt"] --> Q1{"Which tier?"}
  Q1 -->|"premium / up to 4K"| B["gemini-3-pro-image<br/>Nano Banana Pro"]
  Q1 -->|"workhorse / balanced"| C["gemini-3.1-flash-image<br/>Nano Banana 2"]
  Q1 -->|"cheapest / lowest latency"| D["gemini-3.1-flash-lite-image<br/>Nano Banana 2 Lite"]
  B --> E["parts·inlineData·data<br/>- base64 PNG"]
  C --> E
  D --> E
  E --> F["Cache once in R2"]

All three tiers call generateContent and return the image as an inline-data part — only the model ID changes. Per-image output prices (paid tier, verified 2026-09-01):

Tier Model ID Price / image
Nano Banana Pro — world knowledge, brand consistency, up to 4K gemini-3-pro-image $0.134 (1K/2K) · $0.24 (4K)
Nano Banana 2 — balanced generalist workhorse gemini-3.1-flash-image $0.067 (1K) · $0.101 (2K) · $0.151 (4K)
Nano Banana 2 Lite — cheapest, ultra-low latency (GA 2026-06-30) gemini-3.1-flash-lite-image $0.0336 (1K)
const res = await ai.models.generateContent({
  model: "gemini-3.1-flash-image", // or "gemini-3-pro-image" for premium
  contents: "A glossy 3D emblem of a green database with a lightning bolt",
});
const part = res.candidates?.[0]?.content?.parts?.find((p) => p.inlineData);
const bytes = part?.inlineData?.data; // base64 PNG

REST — the same call this repo's scripts/generate-thumbnails.py uses to build the card thumbnails (it currently pins gemini-2.5-flash-image, the original Nano Banana — still available, now the legacy tier):

curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" -H "Content-Type: application/json" \
  -d '{"contents":[{"parts":[{"text":"A colorful app-icon logo"}]}]}'
# response: candidates[].content.parts[].inlineData.data (base64 PNG)

Models (verified 2026-09-01)

Model Use
gemini-3.7-flash current latest stable flash — text / multimodal reasoning, 1M-token context
gemini-3.6-flash previous stable flash — same promo pricing as 3.7
gemini-3.5-flash legacy flash, still GA — routine high-throughput work
gemini-3.5-flash-lite cheapest 3.5-line model
gemini-3.1-flash-lite lowest-cost / highest-QPS text model
gemini-3.1-pro-preview Gemini 3 Pro — highest-capability reasoning (preview)
gemini-3-pro-image premium image gen/edit — "Nano Banana Pro" (up to 4K)
gemini-3.1-flash-image workhorse image gen/edit — "Nano Banana 2"
gemini-3.1-flash-lite-image cheapest image gen — "Nano Banana 2 Lite" (GA 2026-06-30)
gemini-2.5-flash-image legacy image model — original "Nano Banana" (still available)
gemini-3.5-transcribe speech-to-text with diarization + language detection (stable)
gemini-2.5-flash / gemini-2.5-pro previous-generation text models (still available)

Retired: all Imagen 4 IDs (imagen-4.0-generate-001 / -ultra- / -fast-) and Imagen 3 were shut down 2026-08-17 — migrate to the Nano Banana models above. Flash IDs iterate fast; re-verify from the models page or use gemini-flash-latest.

List live models for a key: GET https://generativelanguage.googleapis.com/v1beta/models with header x-goog-api-key: $GEMINI_API_KEY.

Errors, rate limits & retries

The Gemini API returns standard HTTP codes with a canonical status name. The ones worth retrying are transient (rate limit + server-side); the rest are bugs in your request and retrying just wastes quota.

Code Status Meaning Retry?
400 INVALID_ARGUMENT malformed request / bad field No — fix the call
403 PERMISSION_DENIED wrong/blocked key (e.g. a Maps key) No — fix the key
429 RESOURCE_EXHAUSTED you exceeded the rate limit / quota Yes — backoff
500 INTERNAL unexpected error on Google's side Yes — backoff
503 UNAVAILABLE service temporarily overloaded / down Yes — backoff
504 DEADLINE_EXCEEDED request didn't finish in time Raise client timeout / shrink prompt

Authoritative tables: the troubleshooting page (error codes) and the rate-limits page (tiers). The official docs do not prescribe a backoff algorithm, so the snippet below is a standard exponential-backoff-with-jitter pattern applied to the documented retryable codes.

The @google/genai SDK throws an ApiError that extends Error with a .status field holding the HTTP code — so you branch on .status, not on string matching.

import { GoogleGenAI, ApiError } from "@google/genai";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const RETRYABLE = new Set([429, 500, 503]); // RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** Run a Gemini call with exponential backoff + jitter on transient errors. */
async function withBackoff<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = err instanceof ApiError ? err.status : undefined;
      if (attempt >= maxRetries || status === undefined || !RETRYABLE.has(status)) {
        throw err; // out of retries, or a non-retryable error like 400/403
      }
      // 1s, 2s, 4s, 8s ... capped at 30s, plus up to 1s of jitter
      const delay = Math.min(2 ** attempt * 1000, 30_000) + Math.random() * 1000;
      await sleep(delay);
    }
  }
}

const res = await withBackoff(() =>
  ai.models.generateContent({
    model: "gemini-3.7-flash",
    contents: "Explain M-Pesa STK Push in one sentence.",
  }),
);
console.log(res.text);

Free tier vs paid. The free tier has tight per-minute and per-day quotas; once you enable billing your project moves to a paid usage tier with much higher limits. Exact RPM/TPD/RPD numbers vary by model and tier and change over time, so do not hard-code them — read your project's live limits in Google AI Studio and on the rate-limits page. For the thumbnail pipeline, image generation is metered separately and per-image, so a single 429 burst on the free tier is common — backoff plus caching in R2 keeps it cheap.

flowchart TD
  A["Gemini call"] --> B{"ApiError status"}
  B -->|"429 · 500 · 503"| C{"retries left"}
  B -->|"400 · 403"| D["Throw now<br/>fix request or key"]
  B -->|"none · success"| E["Return result"]
  C -->|"yes"| F["Wait 2^n s<br/>plus jitter"]
  F --> A
  C -->|"no"| G["Throw last error"]

Gotcha: retrying a 400/403 is pointless and, with a 429, a tight retry loop with no backoff just digs the quota hole deeper — each rejected call can still count against your rate budget. Only retry the codes in the table above, always with growing delays, and cap total attempts.

codeAmani notes

Official docs:

Google Antigravity Integration Guide

Technology: antigravity · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/antigravity

Insight:

Antigravity is an agent-first development platform, not an editor with AI bolted on: the unit of work is an agent you dispatch, watch, and verify through Artifacts (task lists, plans, screenshots, browser recordings) rather than a file you type into. The 2.0 pivot (I/O 2026) made the standalone desktop app the command center and shipped a Go-based agy CLI that drives the same agent harness from a terminal — a natural fit for a Linux/WSL dev box. It is model-agnostic (Gemini 3.x, Claude Sonnet/Opus 4.6, GPT-OSS), so codeAmani can route reasoning agents to Claude per the AI policy while keeping Gemini's generous free quota for the rest. Still public preview and free for individuals, so treat surfaces and quotas as moving targets.

 █████╗ ███╗   ██╗████████╗██╗ ██████╗ ██████╗  █████╗ ██╗   ██╗██╗████████╗██╗   ██╗
██╔══██╗████╗  ██║╚══██╔══╝██║██╔════╝ ██╔══██╗██╔══██╗██║   ██║██║╚══██╔══╝╚██╗ ██╔╝
███████║██╔██╗ ██║   ██║   ██║██║  ███╗██████╔╝███████║██║   ██║██║   ██║    ╚████╔╝
██╔══██║██║╚██╗██║   ██║   ██║██║   ██║██╔══██╗██╔══██║╚██╗ ██╔╝██║   ██║     ╚██╔╝
██║  ██║██║ ╚████║   ██║   ██║╚██████╔╝██║  ██║██║  ██║ ╚████╔╝ ██║   ██║      ██║
╚═╝  ╚═╝╚═╝  ╚═══╝   ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝  ╚═══╝  ╚═╝   ╚═╝      ╚═╝

Google Antigravity Integration Guide

Focus: running Antigravity on a Linux / WSL Ubuntu dev box — the standalone desktop app (Antigravity 2.0) as the agent command center, and the agy CLI for driving agents from a terminal. Install, launching and orchestrating agents, the Agent Manager panel, Artifacts, parallel subagents, scheduled tasks (Sidecars), model selection, and headless/CI use. The IDE and Python SDK are covered as secondary pointers. Grounded in antigravity.google/docs; reviewed 2026-08-23.

Overview

Google Antigravity is Google's agentic development platform — "build in the agent-first era." Instead of an editor where you type and an AI suggests, the primitive is an agent you give a goal to; it autonomously plans and executes across the editor, terminal, and browser, and reports back through Artifacts — task lists, implementation plans, screenshots, and browser recordings you review instead of scrolling raw logs. It launched in public preview (Nov 2025) and pivoted at I/O 2026 (2026-05-19) to put multi-agent orchestration front and centre, with a standalone desktop app, a Go-based CLI, and a Python SDK.

The platform is one agent harness exposed through four surfaces. Pick the surface, not a different tool:

Surface What it is Reach for it when
Antigravity 2.0 (desktop app) Standalone desktop command center — launch, monitor, and orchestrate many agents sync/async across Projects You want the flagship experience: parallel agents, Artifact review, Sidecars/scheduled tasks
agy CLI Lightweight, keyboard-centric terminal UI (Go rewrite) on the same harness You live in a terminal / SSH / WSL, or need headless runs in CI
Antigravity IDE / extensions The AI-powered editor view; plus extensions for VS Code, Visual Studio, JetBrains, Zed You want inline Tab completions and a side panel inside your existing editor
Antigravity SDK Python SDK (google-antigravity) to build custom agents programmatically You're scripting agents / embedding the harness in your own code

It is model-agnostic. The reasoning-model selector currently offers Gemini 3.7 / 3.6 / 3.5 Flash, Gemini 3.1 Pro, Claude Sonnet 4.6 (thinking), Claude Opus 4.6 (thinking), and GPT-OSS-120b, gated by plan (Gemini on every tier; Claude and GPT-OSS excluded on Enterprise). Gemini and Claude/GPT-OSS draw from separate weekly + five-hour quota pools.

flowchart TB
  G["Your goal / prompt"] --> H["Antigravity agent harness"]
  subgraph SURF["Surfaces (same harness)"]
    D["Antigravity 2.0<br/>desktop command center"]
    C["agy CLI<br/>terminal · headless"]
    I["IDE + extensions<br/>editor view"]
    S["SDK<br/>google-antigravity (py)"]
  end
  H --> D
  H --> C
  H --> I
  H --> S
  H --> M["Model selector<br/>Gemini 3.x · Claude 4.6 · GPT-OSS"]
  D --> SUB["Subagents<br/>parallel background tasks"]
  C --> SUB
  SUB --> ART["Artifacts<br/>plans · screenshots<br/>browser recordings"]
  D --> SC["Sidecars<br/>scheduled / recurring tasks"]
  H --> ENV["Editor · Terminal · Chrome"]

See also: cursor/ and vscode/ for the other editor options this repo documents, and google-ai-studio/ / google-cloud/ for the Gemini API and Vertex side that the SDK's vertex=True mode talks to.

Official Documentation

Resource URL
Platform overview https://antigravity.google/docs/overview
Getting started (desktop) https://antigravity.google/docs/getting-started
Models & quotas https://antigravity.google/docs/models
Artifacts https://antigravity.google/docs/artifacts
Sidecars (scheduled tasks) https://antigravity.google/docs/sidecars
CLI install & auth https://antigravity.google/docs/cli/install
CLI background tasks & subagents https://antigravity.google/docs/cli/subagents
CLI headless mode https://antigravity.google/docs/cli/headless
Python SDK overview https://antigravity.google/docs/sdk/overview
Download https://antigravity.google/download
Launch blog https://developers.googleblog.com/build-with-google-antigravity-our-new-agentic-development-platform/
Getting-started codelab https://codelabs.developers.google.com/getting-started-google-antigravity

Install — Linux / WSL

Two independent installs: the desktop app (a GUI, so on WSL you need WSLg or run it natively on Linux) and the agy CLI (headless, the right fit for a WSL Ubuntu shell). Most codeAmani work on a Windows-hosted WSL box leans on the CLI; install the desktop app on native Linux or via WSLg.

The agy CLI (primary for a terminal / WSL box)

The install script downloads the agy binary to ~/.local/bin/agy:

# Inside your WSL Ubuntu (or any Linux) shell
curl -fsSL https://antigravity.google/cli/install.sh | bash

# Make sure ~/.local/bin is on PATH (add to ~/.bashrc if missing)
export PATH="$HOME/.local/bin:$PATH"

agy --version          # verify the binary is on PATH
agy                    # first launch runs the one-time setup + sign-in

On Windows PowerShell (only if you also want it outside WSL):

irm https://antigravity.google/cli/install.ps1 | iex

Auth is stored in the OS's native secure keyring — Linux Secret Service / dbus, Apple Keychain, or Windows Credential Manager — so the token never lands in a dotfile. On a headless Linux box with no keyring daemon, expect to complete auth interactively once before headless runs work.

The desktop app (Antigravity 2.0)

Download from https://antigravity.google/download — macOS (Apple Silicon / Intel), Windows 10 64-bit (x64 / ARM64), Linux x64. On Debian / Ubuntu, add Google's signed apt repo and install the antigravity package:

# 1. Add the repo signing key
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://us-central1-apt.pkg.dev/doc/repo-signing-key.gpg \
  | sudo gpg --dearmor --yes -o /etc/apt/keyrings/antigravity-repo-key.gpg

# 2. Register the repository
echo "deb [signed-by=/etc/apt/keyrings/antigravity-repo-key.gpg] https://us-central1-apt.pkg.dev/projects/antigravity-auto-updater-dev/ antigravity-debian main" \
  | sudo tee /etc/apt/sources.list.d/antigravity.list > /dev/null

# 3. Install (rpm-based distros and a source tarball are also offered)
sudo apt update
sudo apt install antigravity

Two very different antigravity names — do not confuse them. sudo apt install antigravity (from Google's apt repo above) installs the desktop app. pip install antigravity is the XKCD joke package that opens a comic in your browser — it is not Google's SDK. The real Python SDK is google-antigravity (see the SDK section). Getting these crossed is the single most common Antigravity footgun.


The desktop app — Antigravity 2.0

Antigravity 2.0 is "your AI agents' central command center." Unlike its predecessor (the in-IDE Agent Manager surface), 2.0 is a standalone application: a unified place to launch, monitor, and orchestrate agents both synchronously and asynchronously. Within it, agents can execute system commands, read/write files, call Skills and MCP servers, manage subagents, drive Chrome, and produce Artifacts / implementation plans.

The core loop

  1. Create a Project. Each Project keeps its own isolated context and settings — point it at a repo directory. (Analogous to a workspace; keep the checkout on the Linux filesystem, not /mnt/c, on WSL.)
  2. Start an Agent. Type your goal and dispatch. The agent plans, then executes across editor/terminal/browser.
  3. Navigate. The Conversation Picker is Ctrl+K (⌘K on macOS). Slash commands drive turns — e.g. /goal runs until the specified task is complete.
  4. Review Artifacts, not logs. The agent emits task lists, plan walkthroughs, screenshots, and browser recordings; you verify the deliverable rather than reading raw tool output. Agents can also save useful context and snippets to a knowledge base.

Parallel agents & subagents

The whole point of 2.0 is orchestration: run multiple agents at once, and let a primary agent delegate to parallel subagents for slow builds, multi-file generation, or research sweeps while you keep working. Subagents show up with specialized roles (e.g. "Codebase Researcher", "Database Debugger") and a live checklist of active / completed / killed / failed threads.

Scheduled & recurring tasks — Sidecars

Sidecars are background processes Antigravity manages (auto-launch, auto-restart on crash), used for persistent scripts, scheduled recurring tasks, and reacting to events. They are discovered from sidecar.json files:

// ~/.gemini/config/sidecars/nightly-audit/sidecar.json
{
  "display_name": "Nightly dependency audit",
  "description": "Runs npm audit + gitleaks and files an artifact",
  "command": "python3",
  "args": ["audit.py"],
  "restart_policy": "on-failure"
}

Model selection

Pick the reasoning model from the dropdown under the prompt box. The choice is sticky per turn — changing it mid-run doesn't take effect until the current turn finishes. Track your remaining weekly / five-hour quota there too (Gemini models and Claude/GPT-OSS models have separate pools).


The agy CLI — driving agents from a terminal

Same harness, keyboard-first, in the terminal — which on a Windows box means inside WSL, so it inherits the Linux toolchain automatically. This is the surface for SSH, tmux, and CI.

Interactive

agy                      # start an interactive session in the current project

Inside a session, subagents and background work are managed with slash commands:

/agents      open the interactive Agent Manager Panel (live checklist of background agents)
/tasks       monitor running background tasks
/usage       show model quotas remaining
/diff        review pending modifications
/permissions manage tool-approval gates
/resume      resume a previous conversation

Navigation ergonomics include "Teleport" jump-to-agent (Alt+J) and "Fast-Path" confirmations. A Vim editor mode is available in settings.

Execution modes

The agent runs in one of a few modes, cycled during a session or pinned via agentMode in settings.json (or a command-line flag):

Mode Behaviour
default Proposes modifications for your review before applying
accept-edits Auto-approves edits (still previews new-file creation)
plan Produces a reviewable plan before touching anything

Headless / CI

Headless (a.k.a. print) mode sends a single prompt and exits — the building block for scripts and pipelines:

# One-shot; answer goes to stdout, everything else (auth/progress/permissions) to stderr
agy -p "In one sentence, what is a git rebase?"

# Capture cleanly — the stdout/stderr split makes this safe
answer=$(agy -p "List three popular version control systems, comma-separated.")

# Machine-readable output for pipelines
agy -p "summarize the diff" --output-format json      # text | json | streaming-json
agy -p "review these changes" --output-format streaming-json | jq .

MCP, subagents, sandbox

The CLI supports MCP servers, plugins & skills, a sandbox for command execution, and granular permission gates — the same building blocks as the desktop app. The /agents panel delegates slow work to parallel background subagents so the main flow stays responsive.

Deeper subagent semantics — lifecycle state diagrams, inter-agent messaging, and nesting-depth limits — live in the Antigravity 2.0 subagents docs; the CLI page is the terminal-facing subset.


Secondary surfaces (pointers)

Python SDK — google-antigravity

For building custom agents in code. This is the real SDK (not the antigravity XKCD package):

pip install google-antigravity          # requires Python 3.10+
import asyncio
from google.antigravity import Agent, LocalAgentConfig

async def main():
    async with Agent(LocalAgentConfig()) as agent:
        response = await agent.chat("Hello!")
        print(await response.text())

asyncio.run(main())

Agent is an async context manager that handles binary discovery, tool execution, and session lifecycle. It runs against the local harness by default, or Vertex AI with LocalAgentConfig(vertex=True, project="...", location="us-central1") (or the GOOGLE_GENAI_USE_VERTEXAI / GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION env vars + gcloud auth application-default login). It exposes Personas, Tools & skills, MCP, subagents, structured output, and lifecycle hooks. Runnable examples: github.com/google-antigravity/antigravity-sdk-python under examples/getting_started/. See docs/sdk/overview.

IDE & extensions

The Antigravity IDE (editor view) offers Tab completions, inline commands, a side panel, change review, and Chrome control (with allowlist/denylist and a separate Chrome profile). Extensions bring the harness into VS Code, Visual Studio, JetBrains, and Zed. If your day-to-day editor is already Cursor or VS Code, that route may fit better than switching editors — see cursor/ and vscode/.


codeAmani notes

Official docs:

Google Cloud Developer Training & Resource Guide

Technology: google-cloud · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/google-cloud

Insight:

Google Cloud is the heavyweight option — a full compute spectrum (Functions → Cloud Run → GKE → Compute Engine), every database shape (Cloud SQL, AlloyDB, Spanner, Firestore, Bigtable, BigQuery), and first-class AI via Vertex + Gemini. Two ideas unlock the rest: IAM (every API call resolves to a roles/* check, so scope service accounts tightly and prefer Workload Identity Federation over JSON keys) and the pricing model (serverless bills only while serving, with a real always-free tier; VMs bill 24/7). Cloud Shell gives you a pre-authed gcloud terminal in the browser. Prices below are us-central1 / Tier-1 list, reviewed 2026-08-23 — always reconcile against the official Pricing Calculator. Note the Maps key is NOT a Gemini key (see google-ai-studio), and Gemini on Vertex now goes through the Google Gen AI SDK (google-genai / @google/genai) — the old vertexai.generative_models modules were removed 2026-06-24.

 ██████╗  ██████╗  ██████╗  ██████╗ ██╗     ███████╗     ██████╗██╗      ██████╗ ██╗   ██╗██████╗
██╔════╝ ██╔═══██╗██╔═══██╗██╔════╝ ██║     ██╔════╝    ██╔════╝██║     ██╔═══██╗██║   ██║██╔══██╗
██║  ███╗██║   ██║██║   ██║██║  ███╗██║     █████╗      ██║     ██║     ██║   ██║██║   ██║██║  ██║
██║   ██║██║   ██║██║   ██║██║   ██║██║     ██╔══╝      ██║     ██║     ██║   ██║██║   ██║██║  ██║
╚██████╔╝╚██████╔╝╚██████╔╝╚██████╔╝███████╗███████╗    ╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝
 ╚═════╝  ╚═════╝  ╚═════╝  ╚═════╝ ╚══════╝╚══════╝     ╚═════╝╚══════╝ ╚═════╝  ╚═════╝ ╚═════╝

Google Cloud Developer Training & Resource Guide

Focus: A developer's working map of Google Cloud — what each service is for, how to implement it (gcloud + SDK), what it costs (officially-sourced list prices + free tier), and where it bites. Sourced from cloud.google.com and docs.cloud.google.com; pricing reviewed 2026-08-23. Pricing changes — always confirm on the Pricing Calculator.


How to use this guide

This is a training resource, not just an integration cheat-sheet. Read it in three passes:

  1. Mental model → The GCP spine and the compute decision tree. Internalise these and 80% of "which service?" questions answer themselves.
  2. Service-by-service → each section below gives Use case · Implement · Cost · Gotcha. Skim the table, dive where you need.
  3. Hands-on → the interactive learn module above this page (Cloud Run cost explorer), the official learning paths, and the codeAmani notes for East-Africa-specific guidance.

💡 Per the codeAmani docs policy: when you implement against any of these APIs, pull current docs via the Context7 MCP (resolve-library-id → query-docs) or the official URL in each section — don't code from memory. GCP ships fast.


The GCP spine (a mental model)

Google Cloud is the same infrastructure Google runs Search, Gmail, and YouTube on, rented out. Ignore the 200-product catalogue; a product developer reaches for ~30 services across eight layers:

Layer Services you'll actually use
Compute Cloud Run · Cloud Run functions · GKE · Compute Engine · App Engine
Databases Cloud SQL · AlloyDB · Spanner · Firestore · Bigtable · Memorystore
Analytics BigQuery · Dataflow · Pub/Sub
Storage Cloud Storage · Persistent Disk · Filestore
AI / ML Vertex AI · Gemini API · Agent Builder / Agent Engine · Vector Search
Networking VPC · Cloud Load Balancing · Cloud CDN · Cloud Armor · Cloud DNS
Security & Identity IAM · Secret Manager · Cloud KMS · Workload Identity Federation
DevOps & Ops Cloud Build · Artifact Registry · Cloud Deploy · Cloud Logging/Monitoring · Cloud Scheduler · Cloud Tasks · Workflows · Eventarc

Two cross-cutting ideas hold it together:


Official Documentation

Resource URL
Google Cloud Docs (root) https://cloud.google.com/docs
gcloud CLI reference https://cloud.google.com/sdk/gcloud
Pricing Calculator (source of truth) https://cloud.google.com/products/calculator
Free Tier & $300 trial https://cloud.google.com/free/docs/free-cloud-features
Architecture Center (reference patterns) https://cloud.google.com/architecture
Cloud Skills Boost (training/labs) https://www.cloudskillsboost.google/
Cloud Run https://cloud.google.com/run/docs
Compute Engine https://cloud.google.com/compute/docs
GKE (Kubernetes) https://cloud.google.com/kubernetes-engine/docs
BigQuery https://cloud.google.com/bigquery/docs
Cloud SQL https://cloud.google.com/sql/docs
Firestore https://cloud.google.com/firestore/docs
Cloud Storage https://cloud.google.com/storage/docs
Vertex AI https://cloud.google.com/vertex-ai/docs
IAM & Admin https://cloud.google.com/iam/docs
Secret Manager https://cloud.google.com/secret-manager/docs
Pub/Sub https://cloud.google.com/pubsub/docs
Cloud Build https://cloud.google.com/build/docs
Cloud Shell https://cloud.google.com/shell/docs

Getting started: SDK, auth, and your first project

Install the Google Cloud SDK (gcloud)

# macOS
brew install --cask google-cloud-sdk

# Linux / WSL
curl https://sdk.cloud.google.com | bash
exec -l $SHELL

# Windows — download the installer:
# https://cloud.google.com/sdk/docs/install

gcloud init          # interactive: pick account + project + region
gcloud components install gke-gcloud-auth-plugin   # if you'll use GKE

No install at all? Open Cloud Shell — a pre-authed gcloud terminal in the browser.

The three ways to authenticate

# 1. You, interactively (local dev, console-style work)
gcloud auth login

# 2. Application Default Credentials — what the SDKs/libraries pick up
gcloud auth application-default login

# 3. A service account (CI/CD, servers). Prefer Workload Identity Federation
#    (below) over downloading a JSON key whenever you can.
gcloud auth activate-service-account --key-file=service-account.json

gcloud config set project my-project-id
gcloud config set run/region africa-south1     # set a default region

★ Why ADC matters — Google's client libraries don't take a key argument; they walk the Application Default Credentials chain (env var GOOGLE_APPLICATION_CREDENTIALS → gcloud user creds → attached service account on GCP). Authenticate once with gcloud auth application-default login and every SDK call "just works" locally with your identity. On Cloud Run/GKE/Compute Engine, the attached service account is the identity — no key files in production.

Enable an API before you call it

Nearly every "permission/404" on a fresh project is a disabled API:

gcloud services enable run.googleapis.com bigquery.googleapis.com \
  secretmanager.googleapis.com aiplatform.googleapis.com
gcloud services list --enabled

Cost & billing model (the part that bites)

All figures below are us-central1 / Tier-1 list prices, reviewed 2026-08-23, from cloud.google.com. Regional pricing varies; cold-tier storage adds retrieval + egress fees. Treat the Pricing Calculator as canonical.

Always-Free tier (per billing account, every month — not the trial)

Source: Free cloud features.

Product Always-free monthly allowance
$300 trial credit Spendable over 90 days (one-time, new accounts)
Compute Engine 1 e2-micro VM (us-west1/us-central1/us-east1) + 30 GB-mo standard PD
Cloud Storage 5 GB-mo regional (US regions)
Cloud Run 2M requests + 180,000 vCPU-s + 360,000 GiB-s (free-tier doc)
Cloud Run functions 2M invocations + 200,000 GHz-s + 400,000 GB-s
BigQuery 1 TiB queried + 10 GiB storage
Firestore 1 GiB stored + 50k reads / 20k writes / 20k deletes per day
Pub/Sub 10 GiB messages
Cloud Build 2,500 build-minutes
Secret Manager 6 active secret versions + 10,000 access ops
Cloud Logging first 50 GiB ingested per project

⚠️ The Cloud Run pricing page lists the free tier as 240,000 vCPU-s + 450,000 GiB-s (applied as a spend-based discount at Tier-1 rates), while the free-tier doc lists 180,000 / 360,000. They're published in two places — reconcile on the Calculator for your region.

Headline rates (us-central1, Tier-1 list)

Service What you pay for Rate
Cloud Run (instance-based) vCPU-second $0.00001800 / vCPU-s
memory $0.00000200 / GiB-s
requests (request-based mode) $0.40 / million
GKE cluster management $0.10 / cluster / hour (all clusters)
Autopilot per-second vCPU + memory + ephemeral-storage requested by Pods
BigQuery (on-demand) bytes scanned $6.25 / TiB (first 1 TiB/mo free)
active storage ~$0.02 / GiB-mo (first 10 GiB free)
Cloud Storage Standard at-rest (regional, US) ~$0.020 / GB-mo
Nearline / Coldline / Archive descending (~$0.010 / ~$0.004 / ~$0.0012) + retrieval fees
Compute Engine E2 VMs billed 24/7; Spot 60–91% off, CUD up to 55%

Discount levers (provisioned services)

Don't get surprised

# Set a budget + alert (do this on day one)
gcloud billing budgets create --billing-account=BILLING_ID \
  --display-name="codeamani-monthly" \
  --budget-amount=50USD \
  --threshold-rule=percent=0.5 --threshold-rule=percent=0.9

# Egress (data leaving Google) is the silent cost — same-region traffic
# between your services is usually free; cross-region and internet egress are not.

Compute

Pick the right compute primitive in one decision — start at the top and follow the arrows:

flowchart TD
  Q1{"Stateless container<br/>serving requests?"} -->|Yes| Q2{"One function<br/>covers it?"}
  Q1 -->|No| Q3{"Need Kubernetes?"}
  Q2 -->|Yes| F["Cloud Run functions"]
  Q2 -->|No| R["Cloud Run"]
  Q3 -->|Yes| G["GKE Autopilot"]
  Q3 -->|No| C["Compute Engine VM"]

Choosing compute: the decision tree

Is it a stateless container that responds to requests/events?
├─ Yes → does a single function/endpoint cover it?
│        ├─ Yes → Cloud Run functions   (smallest unit, event triggers)
│        └─ No  → Cloud Run             (any container, scale-to-zero, websockets, jobs)
└─ No  → do you need Kubernetes / multi-container orchestration / service mesh?
         ├─ Yes → GKE  (Autopilot first; Standard for node-level control)
         └─ No  → need a full OS, GPU, long-running daemon, or custom kernel?
                  ├─ Yes → Compute Engine (VMs; Spot for batch)
                  └─ Legacy/managed PaaS → App Engine

Rule of thumb for codeAmani: start every service on Cloud Run. Graduate to GKE only when you genuinely need Kubernetes primitives, and to Compute Engine only for stateful/GPU/long-running work. Most M-Pesa + Next.js products never leave Cloud Run.

Cloud Run — serverless containers (start here)

# Deploy straight from source — Cloud Build builds the container for you
gcloud run deploy my-api --source . --region africa-south1 --allow-unauthenticated

# …or from a prebuilt image in Artifact Registry (gcr.io/Container Registry was
# shut down 2025-03-18 — new images live in *-docker.pkg.dev)
gcloud run deploy my-api \
  --image africa-south1-docker.pkg.dev/PROJECT/app/my-image:latest --region africa-south1

gcloud run services logs tail my-api --region africa-south1
gcloud run jobs create nightly-recon \
  --image africa-south1-docker.pkg.dev/PROJECT/app/recon:latest && \
gcloud run jobs execute nightly-recon

Cloud Run functions — event-driven snippets

gcloud functions deploy thumb-maker \
  --gen2 --runtime=nodejs24 --region=africa-south1 \
  --trigger-bucket=codeamani-uploads --entry-point=makeThumb

GKE — managed Kubernetes

gcloud container clusters create-auto my-cluster --region africa-south1   # Autopilot
gcloud container clusters get-credentials my-cluster --region africa-south1
kubectl apply -f deployment.yaml

Compute Engine — virtual machines (the escape hatch)

gcloud compute instances create dev-box \
  --zone=africa-south1-a --machine-type=e2-small \
  --image-family=debian-12 --image-project=debian-cloud --tags=http-server
gcloud compute ssh dev-box --zone=africa-south1-a
gcloud compute instances stop dev-box --zone=africa-south1-a   # stop to save $$

Databases

Service Shape Reach for it when…
Cloud SQL Managed Postgres / MySQL / SQL Server You want relational + familiar SQL with zero ops. The default OLTP DB.
AlloyDB Postgres-compatible, HTAP Heavy Postgres workloads needing 4× throughput + analytics on the same data.
Spanner Globally-distributed relational Horizontal scale and strong consistency at global scale.
Firestore Serverless document DB Mobile/web apps, real-time listeners, scale-to-zero, offline sync.
Bigtable Wide-column NoSQL Massive low-latency key/value (time-series, IoT, ad-tech).
Memorystore Managed Redis / Memcached Caching, sessions, rate-limit counters.
BigQuery Serverless analytics warehouse Analytics/BI/petabyte SQL — not an app DB.

Cloud SQL — managed relational (default OLTP)

gcloud sql instances create app-db --database-version=POSTGRES_16 \
  --tier=db-f1-micro --region=africa-south1
gcloud sql databases create app --instance=app-db
# From Cloud Run, connect via the built-in Cloud SQL connector (no public IP needed):
gcloud run deploy my-api --add-cloudsql-instances PROJECT:africa-south1:app-db

Firestore — serverless document DB

import { Firestore } from "@google-cloud/firestore";
const db = new Firestore();
await db.collection("payments").doc(checkoutRequestId).set({ status: "PENDING" });

codeAmani pattern: store the M-Pesa CheckoutRequestID as a Firestore doc ID on STK Push, then dedupe on callback — idempotency for free, and it scales to zero between transactions.


Storage

Cloud Storage — object store

gcloud storage buckets create gs://codeamani-uploads --location=africa-south1
gcloud storage cp ./file.pdf gs://codeamani-uploads/
# Signed URL for a time-limited client upload (no creds on the client):
gcloud storage sign-url gs://codeamani-uploads/file.pdf --duration=15m

AI / ML

Vertex AI + Gemini

# pip install google-genai   — the unified Google Gen AI SDK.
# The old vertexai.generative_models modules were REMOVED 2026-06-24; use this instead.
from google import genai

# vertexai=True routes to Vertex (IAM/residency); drop it + pass api_key for AI Studio.
client = genai.Client(vertexai=True, project="PROJECT", location="us-central1")
resp = client.models.generate_content(
    model="gemini-2.5-flash",
    contents="Andika salamu fupi kwa Kiswahili.",
)
print(resp.text)

Agent Builder / Agent Engine — managed agent runtime

pip install "google-cloud-aiplatform[agent_engines,adk]"
gcloud storage buckets create gs://codeamani-agents-staging --location=us-central1   # staging bucket first
import vertexai
from google.adk.agents import Agent
from vertexai.agent_engines import AdkApp

client = vertexai.Client(project="PROJECT", location="us-central1")
agent = Agent(name="support_bot", model="gemini-2.5-pro",
              instruction="Swahili-fluent support agent for codeAmani M-Pesa flows.", tools=[])
engine = client.agent_engines.create(agent_engine=AdkApp(agent=agent), config={
    "staging_bucket": "gs://codeamani-agents-staging",
    "requirements": ["google-cloud-aiplatform[agent_engines,adk]"],
})
print(engine.api_resource.name)

Eventing, scheduling & orchestration

Service Use case One-liner
Pub/Sub Async messaging / fan-out / decoupling gcloud pubsub topics create payments
Eventarc Route GCP events (e.g. GCS upload) → Cloud Run Trigger services from 90+ event sources
Cloud Tasks Reliable async task queues with rate-limit/retry Deferred work, outbound webhooks
Cloud Scheduler Cron-as-a-service gcloud scheduler jobs create http nightly --schedule="0 2 * * *"
Workflows Serverless orchestration of API/service steps YAML state machine across services

DevOps: build, registry, deploy, CI/CD

# cloudbuild.yaml — build, test, push, deploy to Cloud Run
steps:
  - { name: node:24, entrypoint: npm, args: [ci] }
  - { name: node:24, entrypoint: npm, args: [test] }
  - { name: gcr.io/cloud-builders/docker,
      args: [build, -t, "$_REGION-docker.pkg.dev/$PROJECT_ID/app/$_SVC:$COMMIT_SHA", .] }
  - { name: gcr.io/cloud-builders/docker,
      args: [push, "$_REGION-docker.pkg.dev/$PROJECT_ID/app/$_SVC:$COMMIT_SHA"] }
  - { name: gcr.io/google.com/cloudsdktool/cloud-sdk, entrypoint: gcloud,
      args: [run, deploy, "$_SVC", "--image=$_REGION-docker.pkg.dev/$PROJECT_ID/app/$_SVC:$COMMIT_SHA",
             "--region=$_REGION"] }
substitutions: { _SVC: my-api, _REGION: africa-south1 }
options: { logging: CLOUD_LOGGING_ONLY }

Networking (the essentials)

Service What it does
VPC Your private software-defined network; subnets are regional, the VPC is global
Cloud Load Balancing Global anycast L7/L4 LB with a single anycast IP
Cloud CDN Edge caching in front of the LB
Cloud Armor WAF + DDoS protection (rules, rate-limiting, geo)
Cloud DNS Managed authoritative DNS (100% SLA)

Security & Identity

IAM — every call is a permission check

Concept Meaning
Principal Who — user, group, service account, or federated workload identity
Role A permission bundle, e.g. roles/run.invoker, roles/bigquery.dataViewer
Binding A (principal, role, resource) triple — the grant itself
gcloud projects get-iam-policy PROJECT_ID
gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:claude-bot@PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/run.developer"

Least-privilege rules of thumb: predefined roles over roles/owner; bind at the narrowest scope (one bucket / one service, not the whole project); one service account per service.

Secret Manager — runtime secrets

gcloud secrets create DARAJA_KEY --data-file=./key.txt
gcloud secrets versions access latest --secret=DARAJA_KEY
# Mount straight into Cloud Run (never bake secrets into env vars/images):
gcloud run deploy my-api --update-secrets=DARAJA_KEY=DARAJA_KEY:latest

Workload Identity Federation — kill the JSON key

For GitHub Actions and other external CI, federate instead of downloading a service-account key:

# GitHub Actions — short-lived token, no long-lived secret to leak
- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/gh
    service_account: deployer@PROJECT.iam.gserviceaccount.com

See https://cloud.google.com/iam/docs/workload-identity-federation. Service-account JSON keys are radioactive — never commit, never ship to a client.

Cloud KMS — managed encryption keys

Customer-managed encryption keys (CMEK) for data you must control at rest — relevant for KDPA/compliance workloads.


Observability

gcloud logging read 'resource.type=cloud_run_revision severity>=ERROR' --limit 20 --freshness=1h

Cloud Shell (the browser terminal)

A free, pre-authed Debian VM at shell.cloud.google.com: gcloud, gsutil, bq, kubectl, docker, Node, Python; 5 GB persistent $HOME; web preview on port 8080.

gcloud cloud-shell ssh                                   # connect from your terminal
gcloud cloud-shell ssh --command "gcloud run deploy my-api --source ."   # deploy from an iPad

Claude Code integration

gcloud via the Bash tool (most reliable)

// .claude/settings.json
{ "permissions": { "allow": ["Bash(gcloud:*)", "Bash(gsutil:*)", "Bash(bq:*)"] } }

Community MCP server

GCP has no first-party MCP server; community servers wrap the SDK:

{ "mcpServers": { "gcp": { "command": "npx", "args": ["-y", "gcp-mcp"],
  "env": { "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}",
           "GOOGLE_CLOUD_PROJECT": "${GOOGLE_CLOUD_PROJECT}" } } } }

Slash command: analyze Cloud Run logs

<!-- .claude/commands/gcp-logs.md -->
Analyze recent Cloud Run logs for $ARGUMENTS.
Run: gcloud run services logs tail $ARGUMENTS --region africa-south1 --limit 50
Summarise errors, high-latency requests, and crash loops with root causes + fixes.

Environment variables

GOOGLE_CLOUD_PROJECT=my-project-id
GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json   # local only; use ADC/WIF where possible
GOOGLE_CLOUD_REGION=africa-south1

Official learning resources

Train, don't guess. All first-party:

Resource What it is URL
Cloud Skills Boost Google's official courses + hands-on labs (free + paid) https://www.cloudskillsboost.google/
Google Cloud Codelabs Step-by-step build-along tutorials https://codelabs.developers.google.com/
Architecture Center Reference architectures + best-practice patterns https://cloud.google.com/architecture
Well-Architected Framework Design pillars (reliability, security, cost, ops) https://cloud.google.com/architecture/framework
Cloud certifications Associate Cloud Engineer → Professional tracks https://cloud.google.com/learn/certification
Free Tier + $300 trial Build for real at near-zero cost https://cloud.google.com/free
gcloud cheat sheet The 1-pager of essential commands https://cloud.google.com/sdk/docs/cheatsheet

Suggested path for a codeAmani dev: Free Tier sign-up → deploy a container to Cloud Run (Codelab) → wire Firestore + Secret Manager → add a Cloud Build pipeline → layer Vertex/Gemini → read the cost + security pillars of the Well-Architected Framework.


Troubleshooting

Issue Fix
gcloud: command not found Run gcloud init after install; restart shell
403 / API not enabled gcloud services enable <api>.googleapis.com then retry
ADC not configured gcloud auth application-default login
403 permission denied (after API enabled) Grant the role: gcloud projects add-iam-policy-binding …
Cloud Run cold starts --min-instances=1 for latency-sensitive endpoints
Surprise bill Egress / an always-on VM or Cloud SQL — set a budget alert day one
BigQuery quota error Check quotas at console.cloud.google.com/iam-admin/quotas
gcloud compute ssh hangs Open TCP 22: gcloud compute firewall-rules create allow-ssh --allow tcp:22
Agent Engine deploy fails on staging Bucket must exist + match the Agent Engine region
Cloud Shell config gone gcloud config is in a temp dir — persist under $HOME
GKE bill higher than expected The flat $0.10/hr/cluster fee + idle Standard nodes — use Autopilot or Cloud Run

codeAmani notes

Official docs:

Grok Bot Developer Guide

Technology: grok-bot · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/grok-bot

Insight:

A Grok bot is not an API call — it is a stateful loop wrapped around a stateless model. The API reference (see xai/) gives you one turn; a bot has to own the other four jobs: persist the transcript, hold a persona steady, execute tools the model asks for, and deliver a reply into a chat surface that has its own rules (WhatsApp's 24-hour window, Telegram's 4096-char cap). The trade-off worth naming up front: Grok's real-time Live Search and 1M/500k context make it the strongest grounded bot brain, but reasoning latency and per-conversation token growth are the two things that will actually break your build — so cap history, cap steps, meter cost per conversation, and keep a Claude fallback wired behind the same interface.

 ██████╗ ██████╗  ██████╗ ██╗  ██╗    ██████╗  ██████╗ ████████╗
██╔════╝ ██╔══██╗██╔═══██╗██║ ██╔╝    ██╔══██╗██╔═══██╗╚══██╔══╝
██║  ███╗██████╔╝██║   ██║█████╔╝     ██████╔╝██║   ██║   ██║
██║   ██║██╔══██╗██║   ██║██╔═██╗     ██╔══██╗██║   ██║   ██║
╚██████╔╝██║  ██║╚██████╔╝██║  ██╗    ██████╔╝╚██████╔╝   ██║
 ╚═════╝ ╚═╝  ╚═╝ ╚═════╝ ╚═╝  ╚═╝    ╚═════╝  ╚═════╝    ╚═╝

Grok Bot Developer Guide

Focus: building a bot on Grok — the webhook loop, conversation state, persona design, tool calling, streaming into a chat surface, cost guardrails, and a Grok→Claude fallback. For the raw API surface (model IDs, Live Search, SDK setup, image generation) see xai/CLAUDE_CODE_INTEGRATION.md — this guide deliberately does not repeat it.

Overview

The xAI API is stateless. Every chat.completions.create call is a fresh mind with no memory of the last one. A bot is the machinery you build around that fact:

Job What it means Where it lives
Ingress Receive an inbound message and prove it is real Webhook route + signature verification
Identity Map a phone number / chat ID to a conversation row Your database
State Rebuild the transcript the model needs, and only that History window + summariser
Persona Keep tone, scope, and refusals stable across turns System prompt (instructions)
Action Let the model call your business functions Tool loop
Egress Deliver the reply within the surface's rules Send API + chunking
Economics Know what each conversation cost you Usage logging per turn

Only the middle box is Grok. Six of the seven jobs are yours.

flowchart LR
  A["User in<br/>WhatsApp · Telegram · Discord"] -->|"inbound webhook"| B["Verify signature<br/>+ ACK 200 fast"]
  B --> C["Load conversation<br/>by chat_id"]
  C --> D["Build messages:<br/>persona + summary<br/>+ last N turns"]
  D --> E["Grok<br/>grok-4.6"]
  E -->|"tool_calls"| F["Execute tools<br/>order lookup · M-Pesa STK<br/>delivery quote"]
  F --> E
  E -->|"final text"| G["Chunk + send<br/>via surface API"]
  E -.->|"on 429 / 5xx / timeout"| H["Claude fallback<br/>same tool schema"]
  H --> G
  G --> I["Persist turn<br/>+ log tokens & cost"]
  I --> C

Which surface?

Surface Transport Streaming to user? The rule that will bite you
WhatsApp Cloud API REST over Graph API, pure HTTP (no SDK) No — one message per send The 24-hour window: outside it, only pre-approved templates go through
Telegram Bot API, grammy Simulated via editMessageText 4096-char message cap; 429 with retry_after
Discord Gateway + REST, discord.js Simulated via message edits 3s interaction ACK deadline — deferReply() or it fails

Cross-references in this repo: whatsapp-business-api/ for the messaging surface itself, ai-agents/ for the general agent loop across providers, together-ai/ for the cheap open-model tier that can serve as a second fallback.

Official Documentation

Resource URL
xAI quickstart https://docs.x.ai/developers/quickstart
Function calling https://docs.x.ai/developers/tools/function-calling
Models & pricing https://docs.x.ai/developers/models
Streaming https://docs.x.ai/developers/model-capabilities/text/streaming
Rate limits https://docs.x.ai/developers/rate-limits
AI SDK xAI provider https://ai-sdk.dev/providers/ai-sdk-providers/xai
AI SDK tools & tool calling https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling
WhatsApp Cloud API https://developers.facebook.com/docs/whatsapp/cloud-api
WhatsApp webhooks https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks
Telegram Bot API https://core.telegram.org/bots/api

Setup

Install

npm install @ai-sdk/xai ai zod @ai-sdk/anthropic

@ai-sdk/xai@4 and @ai-sdk/anthropic@4 both build on @ai-sdk/provider@4, which is what ai@7 ships — upgrade the three together or the provider types drift.

Add the messaging client for your surface — WhatsApp Cloud API needs none (plain fetch against the Graph API):

npm install grammy        # Telegram
npm install discord.js    # Discord
npm install openai        # optional: OpenAI-compatible path to api.x.ai/v1

Environment variables

# Grok — server-side only, never NEXT_PUBLIC_*
XAI_API_KEY=xai-...

# Fallback provider (codeAmani AI routing policy)
ANTHROPIC_API_KEY=sk-ant-...

# WhatsApp Cloud API
WHATSAPP_ACCESS_TOKEN=<system user token>
WHATSAPP_PHONE_NUMBER_ID=<from Meta app dashboard>
WHATSAPP_VERIFY_TOKEN=<random string you invent, echoed on GET>
WHATSAPP_APP_SECRET=<for X-Hub-Signature-256 verification>

# Telegram
TELEGRAM_BOT_TOKEN=<from @BotFather>
TELEGRAM_WEBHOOK_SECRET=<random; sent as X-Telegram-Bot-Api-Secret-Token>

# Conversation store
DATABASE_URL=<postgres connection string>

The model

Use a pinned ID, not an alias. As of 2026-08-23 the flagship is grok-4.6 (500k context). grok-4.3 (1M context) is the cheaper long-context option and is a reasonable bot default when transcripts get long. Check https://docs.x.ai/developers/models before hardcoding — the lineup rotates.

// lib/bot/model.ts
export const GROK_MODEL = "grok-4.6" as const;
export const GROK_FALLBACK_MODEL = "grok-4.3" as const;

1. The bot loop

The shape is always the same, whatever the surface. Acknowledge the webhook immediately, then do the work. Meta retries any webhook you do not 200 within seconds, and a retried webhook means a duplicate reply to the user.

// app/api/whatsapp/webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";
import { handleTurn } from "@/lib/bot/turn";

// Meta's verification handshake — runs once, when you register the callback URL
export async function GET(req: Request) {
  const url = new URL(req.url);
  const mode = url.searchParams.get("hub.mode");
  const token = url.searchParams.get("hub.verify_token");
  const challenge = url.searchParams.get("hub.challenge");

  if (mode === "subscribe" && token === process.env.WHATSAPP_VERIFY_TOKEN) {
    return new Response(challenge, { status: 200 });
  }
  return new Response("forbidden", { status: 403 });
}

export async function POST(req: Request) {
  // Signature is computed over the RAW body — read text, never req.json() first
  const raw = await req.text();
  if (!verifySignature(raw, req.headers.get("x-hub-signature-256"))) {
    return new Response("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(raw);
  const msg = payload.entry?.[0]?.changes?.[0]?.value?.messages?.[0];

  // ACK first; run the model after the response is sent.
  if (msg?.type === "text") {
    after(() => handleTurn({ chatId: msg.from, text: msg.text.body, messageId: msg.id }));
  }
  return new Response("ok", { status: 200 });
}

function verifySignature(raw: string, header: string | null) {
  if (!header?.startsWith("sha256=")) return false;
  const expected = createHmac("sha256", process.env.WHATSAPP_APP_SECRET as string)
    .update(raw)
    .digest("hex");
  const a = Buffer.from(header.slice(7), "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

On a platform without after() (or for work longer than the function timeout), push the turn onto a durable queue instead and let a worker run it. Webhook handlers are the wrong place to wait on a reasoning model.

Idempotency. WhatsApp and Telegram both redeliver. Store the inbound provider message ID with a unique constraint and drop duplicates before you spend a token:

const inserted = await db
  .insertInto("bot_messages")
  .values({ chat_id: chatId, provider_message_id: messageId, role: "user", content: text })
  .onConflict((oc) => oc.column("provider_message_id").doNothing())
  .executeTakeFirst();

if (Number(inserted?.numInsertedOrUpdatedRows ?? 0) === 0) return; // already handled

2. Conversation state

The model is stateless; the transcript is a table. The naive version — append every turn forever and resend it — works for a week and then bills you for a 200k-token prompt on every "asante".

create table bot_conversations (
  id           uuid primary key default gen_random_uuid(),
  chat_id      text not null unique,      -- 254712345678, or Telegram chat.id
  surface      text not null,             -- 'whatsapp' | 'telegram' | 'discord'
  summary      text,                      -- rolling compression of older turns
  locale       text not null default 'en',
  last_user_at timestamptz,               -- drives the WhatsApp 24h window check
  created_at   timestamptz not null default now()
);

create table bot_messages (
  id                  bigserial primary key,
  conversation_id     uuid not null references bot_conversations(id) on delete cascade,
  role                text not null,      -- 'user' | 'assistant' | 'tool'
  content             jsonb not null,
  provider_message_id text unique,        -- idempotency key
  prompt_tokens       int,
  completion_tokens   int,
  cached_tokens       int,
  cost_usd            numeric(12,6),
  created_at          timestamptz not null default now()
);

create index on bot_messages (conversation_id, created_at desc);

The window + summary pattern

Keep the last N turns verbatim; compress everything older into one paragraph the persona can read.

// lib/bot/history.ts
import type { ModelMessage } from "ai";

const VERBATIM_TURNS = 12;

export async function buildMessages(conversationId: string): Promise<ModelMessage[]> {
  const convo = await getConversation(conversationId);
  const recent = await getRecentMessages(conversationId, VERBATIM_TURNS);

  const messages: ModelMessage[] = [];
  if (convo.summary) {
    messages.push({
      role: "user",
      content: `[Earlier in this conversation]\n${convo.summary}`,
    });
  }
  for (const m of recent) {
    messages.push({ role: m.role, content: m.content } as ModelMessage);
  }
  return messages;
}

Re-summarise on a threshold, not on every turn — a summary call is a full model call:

export async function maybeCompress(conversationId: string) {
  const count = await countMessagesSinceSummary(conversationId);
  if (count < 24) return;

  const { text } = await generateText({
    model: xai(GROK_FALLBACK_MODEL), // cheap long-context model does compression fine
    instructions:
      "Compress this customer conversation into under 150 words. Preserve: names, " +
      "order numbers, amounts, phone numbers, delivery addresses, and any unresolved " +
      "request. Drop pleasantries. Write in the third person.",
    messages: await getAllMessagesSinceSummary(conversationId),
  });

  await saveSummary(conversationId, text);
}

Prompt caching pays for this shape. xAI routes requests carrying the same x-grok-conv-id header to the same server, which maximises prefix-cache hits — and a bot's prompt is mostly a stable prefix (persona + summary) with a short tail. Send your conversation ID as that header and watch usage.prompt_tokens_details.cached_tokens climb. Details in xai/CLAUDE_CODE_INTEGRATION.md.


3. Persona design

The system prompt is the only thing standing between "helpful shop assistant" and "Grok being Grok at your customer". Treat it as code: version it, test it, and never build it from user input.

// lib/bot/persona.ts
export function buildInstructions(ctx: {
  businessName: string;
  locale: "en" | "sw";
  hoursText: string;
}) {
  return [
    `You are the WhatsApp assistant for ${ctx.businessName}, a shop in Nairobi.`,
    "",
    "## Scope",
    "You handle: product availability, prices in KES, order status, delivery times,",
    "and M-Pesa payment. For anything else, say you'll pass it to a human and stop.",
    "",
    "## Voice",
    "Short. Two or three sentences, then a question or a next step. This is WhatsApp,",
    "not email. No markdown headings, no bullet lists, no emoji unless the customer",
    "used one first.",
    ctx.locale === "sw"
      ? "Reply in the language the customer writes in. Kiswahili and Sheng are both fine; keep numbers and product names in the original."
      : "Reply in English.",
    "",
    "## Hard rules",
    "- Never invent a price, stock level, or order status. Call a tool or say you don't know.",
    "- Never state that a payment succeeded. Only confirm what check_payment_status returns.",
    "- Never ask for an M-Pesa PIN. Nobody legitimate ever does.",
    "- Amounts are whole KES. Phone numbers are 254XXXXXXXXX.",
    `- Shop hours: ${ctx.hoursText}. Outside them, say when you reopen.`,
    "",
    "## Escalation",
    "If the customer is angry, asks for a refund, or repeats a question twice,",
    "call handoff_to_human and say a person will reply shortly. Do not keep trying.",
  ].join("\n");
}

Four things that make the difference between a demo and a shift-long bot:

  1. Scope fence before voice. A model that knows what it must not answer degrades gracefully; a model that only knows its tone will confidently answer anything.
  2. "Call a tool or say you don't know." State this explicitly. It is the single highest-leverage line against hallucinated stock levels and invented order numbers.
  3. An escalation tool. Without one, the model's only options are to keep improvising or to refuse. Give it a third door.
  4. Never interpolate user text into the persona. Customer content belongs in a user message, always. Interpolating it is prompt injection with extra steps.

4. Tool calling from a bot loop

This is where a bot stops being a chat toy. The AI SDK runs the loop for you — the model asks, your execute runs, the result goes back, repeat — bounded by stopWhen.

// lib/bot/tools.ts
import { tool } from "ai";
import { z } from "zod";

export const botTools = {
  check_stock: tool({
    description: "Look up whether a product is in stock and its current price in KES.",
    inputSchema: z.object({
      query: z.string().describe("Product name or SKU as the customer said it"),
    }),
    execute: async ({ query }) => {
      const items = await searchInventory(query);
      return items.map((i) => ({ sku: i.sku, name: i.name, qty: i.qty, price_kes: i.priceKes }));
    },
  }),

  request_mpesa_payment: tool({
    description:
      "Send an M-Pesa STK Push to the customer's phone for a confirmed order. " +
      "Only call this after the customer has explicitly agreed to the total.",
    inputSchema: z.object({
      order_id: z.string().uuid(),
      amount_kes: z.number().int().positive().describe("Whole KES only — Daraja rejects decimals"),
      phone: z.string().regex(/^254\d{9}$/, "Must be 254XXXXXXXXX"),
    }),
    execute: async ({ order_id, amount_kes, phone }) => {
      // Idempotency: one live STK per order. See MPESA_PATTERNS.md.
      const existing = await getLiveCheckout(order_id);
      if (existing) return { status: "already_pending", checkout_request_id: existing.id };

      const res = await stkPush({ order_id, amount: amount_kes, phone });
      await saveCheckoutRequestId(order_id, res.CheckoutRequestID);
      return { status: "prompt_sent", checkout_request_id: res.CheckoutRequestID };
    },
  }),

  check_payment_status: tool({
    description: "Read the recorded status of an M-Pesa checkout. Never guess payment status.",
    inputSchema: z.object({ checkout_request_id: z.string() }),
    execute: async ({ checkout_request_id }) => getCheckoutStatus(checkout_request_id),
  }),

  handoff_to_human: tool({
    description: "Escalate to a human agent. Use for refunds, complaints, or repeated confusion.",
    inputSchema: z.object({ reason: z.string(), urgency: z.enum(["normal", "high"]) }),
    execute: async ({ reason, urgency }) => {
      await openSupportTicket({ reason, urgency });
      return { escalated: true };
    },
  }),
};
// lib/bot/generate.ts
import { xai } from "@ai-sdk/xai";
import { generateText, isStepCount } from "ai";
import { botTools } from "./tools";
import { buildInstructions } from "./persona";
import { GROK_MODEL } from "./model";

export async function runTurn(conversationId: string, ctx: PersonaCtx) {
  const result = await generateText({
    model: xai(GROK_MODEL),
    instructions: buildInstructions(ctx),
    messages: await buildMessages(conversationId),
    tools: botTools,
    // Default is isStepCount(1) — WITHOUT this the loop stops after the first
    // tool call and your user gets an empty reply.
    stopWhen: isStepCount(5),
    temperature: 0.3,
  });

  return {
    text: result.text,
    usage: result.usage,        // v7: totals across ALL steps
    finalUsage: result.finalStep?.usage,
    steps: result.steps.length,
  };
}

Three rules for bot tools

If you would rather own the loop by hand (or you are on the OpenAI-compatible path), the shape xAI documents is:

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.XAI_API_KEY,
  baseURL: "https://api.x.ai/v1",
  timeout: 360_000, // reasoning models think before they answer
});

let messages = [...history];

for (let step = 0; step < 5; step++) {
  const completion = await client.chat.completions.create({
    model: "grok-4.6",
    messages,
    tools: toolSchemas,        // tool_choice: "auto" is the default
  });

  const message = completion.choices[0].message;
  if (!message.tool_calls) break;

  messages.push(message);
  // Parallel tool calls are ON by default — resolve ALL of them before looping.
  for (const tc of message.tool_calls) {
    const result = await runTool(tc.function.name, JSON.parse(tc.function.arguments));
    messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(result) });
  }
}

parallel_tool_calls: false disables multi-call responses if your tools are not safe to run concurrently. With streaming, a function call arrives whole in a single chunk — it is not streamed across deltas.


5. Streaming into a chat surface

Chat surfaces are not terminals. WhatsApp cannot stream at all — one HTTP POST, one bubble. Telegram and Discord "stream" only by editing a message you already sent, and both rate-limit edits.

The pattern that actually works: stream from the model so you can start the clock early and detect stalls, but deliver on sentence boundaries.

// lib/bot/stream-telegram.ts
import { xai } from "@ai-sdk/xai";
import { streamText, isStepCount } from "ai";

export async function streamToTelegram(bot: Bot, chatId: number, opts: TurnOpts) {
  await bot.api.sendChatAction(chatId, "typing"); // clears after ~5s; re-send on long turns

  const result = streamText({
    model: xai(GROK_MODEL),
    instructions: opts.instructions,
    messages: opts.messages,
    tools: botTools,
    stopWhen: isStepCount(5),
  });

  let buffer = "";
  let sent: { message_id: number } | null = null;
  let lastEdit = 0;

  for await (const part of result.stream) {
    if (part.type !== "text-delta") continue;
    buffer += part.text;

    const now = Date.now();
    if (now - lastEdit < 1200) continue;   // Telegram throttles edits; ~1/sec is safe
    lastEdit = now;

    const body = buffer.slice(0, 4096);     // hard cap: 4096 chars per message
    sent = sent
      ? (await bot.api.editMessageText(chatId, sent.message_id, body), sent)
      : await bot.api.sendMessage(chatId, body);
  }

  if (sent && buffer.length) {
    await bot.api.editMessageText(chatId, sent.message_id, buffer.slice(0, 4096));
  }
}

For WhatsApp, drop the edits and send once — but still stream server-side so a stalled generation trips your timeout instead of the platform's:

const { text, usage } = await runTurn(conversationId, ctx);

await fetch(
  `https://graph.facebook.com/v26.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      messaging_product: "whatsapp",
      to: chatId,                                    // 254XXXXXXXXX, no + and no leading 0
      type: "text",
      text: { body: text.slice(0, 4096) },
    }),
  },
);

The 24-hour window is a bot-architecture problem, not a messaging detail. If the last inbound message from this user is older than 24 hours, that free-form text send fails — you must use an approved template instead. So the bot has to check before it generates:

const stale = Date.now() - convo.last_user_at.getTime() > 24 * 60 * 60 * 1000;
if (stale) {
  await sendTemplate(chatId, "conversation_resume", []); // pre-approved, re-opens the window
  return; // don't burn a Grok call on a message you can't deliver
}

Template creation and approval flow: see whatsapp-business-api/CLAUDE_CODE_INTEGRATION.md.


6. Rate limits and cost guardrails

xAI meters two dimensions — requests per second (derived as RPM/60) and tokens per minute — both tiered by cumulative spend. Over the limit you get HTTP 429, and the documented remedy is exponential backoff. For a bot that means a queue, not a retry-in-the-webhook.

// lib/bot/retry.ts
export async function withBackoff<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      const status = err?.status ?? err?.statusCode;
      if (status !== 429 && !(status >= 500 && status < 600)) throw err;
      const wait = Math.min(2 ** i * 500, 8000) + Math.random() * 250; // full jitter
      await new Promise((r) => setTimeout(r, wait));
    }
  }
  throw lastErr;
}

Instrument cost per conversation

You cannot manage what you do not meter, and "tokens per request" is the wrong unit for a bot — the unit is cost per conversation, because that is what scales with users.

// lib/bot/cost.ts
// Rates per 1M tokens. Verify against https://docs.x.ai/developers/models before trusting.
// grok-4.6 prices step UP above a ~200k-token prompt threshold — the tier matters for
// long transcripts, which is exactly what a bot accumulates.
const RATES = {
  "grok-4.6": { input: 2.0, cachedInput: 0.5, output: 6.0 },
  "grok-4.3": { input: 1.25, cachedInput: 0.2, output: 2.5 },
} as const;

export function turnCostUsd(model: keyof typeof RATES, u: {
  inputTokens: number; outputTokens: number; cachedInputTokens?: number;
}) {
  const r = RATES[model];
  const cached = u.cachedInputTokens ?? 0;
  const fresh = Math.max(0, u.inputTokens - cached);
  return (fresh * r.input + cached * r.cachedInput + u.outputTokens * r.output) / 1_000_000;
}
await db.insertInto("bot_messages").values({
  conversation_id: conversationId,
  role: "assistant",
  content: text,
  prompt_tokens: usage.inputTokens,
  completion_tokens: usage.outputTokens,
  cached_tokens: usage.cachedInputTokens ?? 0,
  cost_usd: turnCostUsd(GROK_MODEL, usage),
}).execute();

Then the guardrails that keep one user from becoming the whole bill:

Guardrail Implementation Why
Per-conversation budget Sum cost_usd for the conversation; over ceiling → handoff_to_human One looping user can outspend a hundred normal ones
Per-user message rate Token bucket keyed on chat_id in Redis/Upstash Bots get spammed; each spam message is a paid inference
Step cap stopWhen: isStepCount(5) Every step is a full prompt resend
History cap VERBATIM_TURNS + summarisation Prompt cost grows linearly with turns otherwise
Output cap maxOutputTokens sized to the surface (WhatsApp bubbles are small) Output tokens cost ~3× input
Global kill switch Feature flag checked before every generate The only thing that stops a runaway at 2am

In AI SDK v7, result.usage is the total across every step — it is no longer per-call. For just the last step use result.finalStep.usage. Logging the wrong one silently under-reports multi-tool turns.


7. Grok → Claude fallback

codeAmani's routing policy makes Anthropic Claude primary for complex reasoning and code gen; Grok earns its slot for real-time grounding and conversational speed. For a bot, the practical framing is different: Grok is the default brain, and Claude is the thing that keeps the bot answering when xAI 429s, times out, or ships a bad deploy.

Make the fallback a boundary, not a branch scattered through the code — one interface, two adapters, the same tool schemas on both sides.

flowchart TD
  A["Turn"] --> B["Grok · grok-4.6"]
  B -->|"ok"| Z["Reply"]
  B -->|"429 / 5xx / timeout"| C{"Retries<br/>exhausted?"}
  C -->|"no"| B
  C -->|"yes"| D["Claude · same tools"]
  D -->|"ok"| Z
  D -->|"fails too"| E["Canned holding reply<br/>+ handoff_to_human"]
  E --> Z
// lib/bot/brain.ts
import { xai } from "@ai-sdk/xai";
import { anthropic } from "@ai-sdk/anthropic";
import { generateText, isStepCount } from "ai";

type TurnInput = { instructions: string; messages: ModelMessage[] };

async function grokTurn(input: TurnInput) {
  return generateText({
    model: xai(GROK_MODEL),
    ...input,
    tools: botTools,
    stopWhen: isStepCount(5),
  });
}

async function claudeTurn(input: TurnInput) {
  return generateText({
    model: anthropic("claude-sonnet-4-6"), // pin the current ID — see anthropic/CLAUDE_CODE_INTEGRATION.md
    ...input,
    tools: botTools,                        // identical schemas — this is the whole point
    stopWhen: isStepCount(5),
  });
}

export async function think(input: TurnInput) {
  try {
    const r = await withBackoff(() => grokTurn(input));
    return { ...r, provider: "xai" as const };
  } catch (err) {
    logProviderFailure("xai", err);
    try {
      const r = await claudeTurn(input);
      return { ...r, provider: "anthropic" as const };
    } catch (err2) {
      logProviderFailure("anthropic", err2);
      return {
        text: "Sorry — I'm having trouble right now. A colleague will reply shortly.",
        provider: "none" as const,
        usage: { inputTokens: 0, outputTokens: 0 },
      };
    }
  }
}

Three things that make a fallback real rather than decorative:


codeAmani notes

Security

AI routing

Grok is the bot brain when the value is conversational latency plus live grounding — a shop assistant that can answer "what's the fuel price today" or "is that team playing tonight" without you building a scraper (Live Search: see xai/). Claude stays primary for the harder offline work around the bot: writing the tool layer, reviewing prompts, and any multi-step reasoning that runs outside the chat turn. Together AI is the cheap tier for high-volume, low-stakes classification (intent tagging, language detection) where a frontier model is waste.

Kenya-targeted projects


Troubleshooting

Symptom Cause Fix
Bot replies with an empty message after using a tool stopWhen left at the v7 default isStepCount(1) Set stopWhen: isStepCount(5)
User gets the same reply twice Webhook redelivered after a slow/failed ACK 200 immediately, run the turn after; dedupe on provider_message_id
WhatsApp send returns an error on a free-form text Outside the 24-hour window Check last_user_at first; re-open with an approved template
401 on the webhook you just deployed Signature computed over parsed JSON HMAC the raw body string, before JSON.parse
429 from api.x.ai under load RPS/TPM tier limit Exponential backoff + queue; raise tier by spend, or fall back to Claude
Costs climb every day with the same user count Unbounded history Cap verbatim turns, add summarisation, send x-grok-conv-id for cache hits
Reported token usage looks too low on tool turns Read finalStep.usage instead of usage v7 usage is the all-steps total; that is the number you want
Replies take 40s+ and users repeat themselves Reasoning latency Send a typing indicator immediately, stream, and raise SDK timeout (~360s)
Telegram edits stop landing mid-stream Edit rate limit Throttle edits to ~1/sec and cap bodies at 4096 chars
Bot invents an order status Persona lacks an explicit "call a tool or say you don't know" rule Add the rule and make the tool the only source of that fact

Official docs:

Hazina MCP Integration Guide

Technology: hazina-mcp · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/hazina-mcp

Insight:

Hazina is codeAmani's own MCP server — a local stdio process over the encrypted secrets vault, not a hosted API. Its 14 tools are deliberately value-free: agents can wire, audit and inject secrets by reference, but no tool returns a plaintext value. The vault lives in WSL, so Windows clients reach it through a wsl.exe bridge.

Hazina MCP Integration Guide

Focus — running codeAmani's first-party Hazina MCP server from WSL and exposing it to Windows-side agents (Cursor's Grok bot, Claude Code desktop) without ever moving the vault.

Overview

Most MCP servers in this stack are vendor-hosted and remote — you point a URL at them and attach a bearer token. Hazina is the opposite: a local stdio server you own, launched as a child process, speaking JSON-RPC over stdin/stdout. There is no network listener and no endpoint to leak.

It fronts the Hazina encrypted secrets vault. The defining design decision is that no MCP tool returns a secret value. Agents get names, references, wiring graphs and readiness reports; the only path a real value takes is inject, which writes a gitignored env file on disk that the agent never reads back. That is what makes it safe to hand an autonomous agent.

Source of truth is Linux. The vault lives at ~/.codeamani/hazina inside WSL. The Windows copy under C:\Users\info\.codeamani\hazina is archive-only — do not create new bindings against it.

Official Documentation

Topic URL
Build an MCP server https://modelcontextprotocol.io/docs/develop/build-server
Connect local (stdio) servers https://modelcontextprotocol.io/docs/develop/connect-local-servers
TypeScript SDK https://github.com/modelcontextprotocol/typescript-sdk
MCP in Claude Code https://docs.claude.com/en/docs/claude-code/mcp
MCP in Cursor https://cursor.com/docs/context/mcp
WSL filesystem / interop https://learn.microsoft.com/en-us/windows/wsl/filesystems

Hazina's own repo is private (codeAmani-Labs/hazina-mcp — the MCP layer split out from the vault package); its docs ship in-tree at docs/INSTALL.md and docs/UBUNTU-GLOBAL.md.

How it is built

Hazina uses the standard MCP TypeScript SDK — McpServer + registerTool with Zod input schemas, connected over StdioServerTransport:

npm install @modelcontextprotocol/sdk zod
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new McpServer({ name: 'hazina', version: VERSION });

server.registerTool(
  'status',
  { description: 'Whether the vault is initialized…', inputSchema: {} },
  async () => ({ content: [{ type: 'text', text: summary }] }),
);

await server.connect(new StdioServerTransport());

Two rules follow from stdio transport and are non-negotiable:

Defense in depth — the value-free guard. Every tool result passes through a guarded() wrapper before it leaves the process: it re-loads the catalog and asserts (assertNoValues) that no secret value appears as a substring of the serialized payload, throwing rather than returning if one does. So even a future buggy handler cannot leak a value across the agent boundary. To avoid false rejections on short non-secret literals (a PORT, a public URL fragment), the assertion only fires on catalog values at or above MIN_ASSERTED_SECRET_LEN = 16 characters. That constant must stay in sync between the vault package and the split-out codeAmani-Labs/hazina-mcp MCP layer — a length one side treats as "too short to be a secret" the other must treat identically.

Install (WSL)

cd ~/projects/hazina
npm install
npm run install:global      # -> ~/.local/bin/hazina  and  ~/.local/bin/hazina-mcp
hazina doctor               # readiness: vault, key, DPAPI path, counts (names only)

The hazina-mcp launcher is a bash wrapper that resolves its own symlinks, then:

Because the launcher supplies its own HAZINA_HOME, client configs do not need an env block — see the WSLENV note under Gotchas.

The 14 tools

All are value-free. Verified live against hazina 0.3.0.

Tool What it returns
status Vault initialized? entry + bound-project counts
doctor Readiness: HAZINA_HOME, vault/key presence, PowerShell DPAPI path
list_refs Every secret reference — path, type, tags, field names
list_projects Project names that have a binding
get_binding A project's env-to-ref mappings (literals shown as literals)
describe_project Each env var, its reference, and status (ok/missing/stale)
wiring_summary Fleet-wide graph of env-to-vault refs grouped by namespace
propose_binding Scans .env.example/.env.local, proposes a binding
bind Create/update a binding: ENV name to vault reference
unbind Remove one ENV name from a binding (does not delete the secret)
inject Resolve a binding, write the gitignored env file with real values
audit Cross-check catalog vs bindings: stale/unused entries
import_from_os_env Import from OS environment (Windows User/Machine/Process)
push Resolve a binding and push values to Vercel or Netlify

There is no reveal / get_value tool. By design.

inject and push are the only tools that move plaintext, and both write it outward (to a gitignored file, or to a host's env store) rather than returning it into the transcript.

Wiring it to clients

Where the client runs decides whether you need the bridge.

Client Runs on Config file Command
Grok Build (CLI) inside WSL ~/.grok/config.toml /home/barnabas/.local/bin/hazina-mcp
Claude Code (in WSL) inside WSL ~/.claude.json /home/barnabas/.local/bin/hazina-mcp
Cursor / Grok bot Windows ~/.cursor/mcp.json wsl.exe bridge
Claude Code desktop Windows ~/.claude.json wsl.exe bridge

Windows clients — the wsl.exe bridge

{
  "mcpServers": {
    "hazina": {
      "command": "wsl.exe",
      "args": ["-d", "Ubuntu", "--", "/home/barnabas/.local/bin/hazina-mcp"]
    }
  }
}

wsl.exe transparently proxies stdin/stdout, so the JSON-RPC stream survives the boundary untouched. The same block works in ~/.cursor/mcp.json and in ~/.claude.json.

WSL-native clients

# ~/.grok/config.toml
[mcp_servers.hazina]
command = "/home/barnabas/.local/bin/hazina-mcp"
claude mcp add --scope user hazina -- /home/barnabas/.local/bin/hazina-mcp

Verifying the bridge

Do not trust the config — speak the protocol to it:

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 | wsl.exe -d Ubuntu -- /home/barnabas/.local/bin/hazina-mcp

Expect "serverInfo":{"name":"hazina","version":"0.3.0"} followed by 14 tools.

Gotchas

Hazina vs the vendor MCPs

Hazina Cloudflare / Vercel / xAI
Transport local stdio (child process) remote HTTP
Config key command + args url + Authorization header
Auth filesystem + DPAPI-wrapped key bearer API token / OAuth
Ownership first-party, private vendor-hosted
Failure mode process will not spawn 401 / network
Secrets exposure none — value-free tools token sits in the config

Cloudflare's are already wired in ~/.cursor/mcp.json (mcp.cloudflare.com/mcp, docs.mcp.cloudflare.com/mcp, bindings.mcp.cloudflare.com/mcp). Note the contrast: those configs carry ${CLOUDFLARE_API_TOKEN} in a header — exactly the kind of sprawl Hazina exists to eliminate. See cloudflare/, vercel/ and xai/ for the vendor-side guides.

codeAmani notes

Official docs:

Hugging Face Integration Guide

Technology: hugging-face · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/hugging-face

Insight:

Hugging Face is home for open-source and fine-tuned models. Its serverless Inference Providers router replaced the old Inference API — one HF token routes to Together, Cerebras, Groq, Fireworks and more behind an OpenAI-compatible endpoint (router.huggingface.co/v1). Reach for it when you need a model you can self-host, fine-tune, or run cheaply (e.g. a Swahili-tuned model) rather than a frontier API.

██╗  ██╗██╗   ██╗ ██████╗  ██████╗ ██╗███╗   ██╗ ██████╗     ███████╗ █████╗  ██████╗███████╗
██║  ██║██║   ██║██╔════╝ ██╔════╝ ██║████╗  ██║██╔════╝     ██╔════╝██╔══██╗██╔════╝██╔════╝
███████║██║   ██║██║  ███╗██║  ███╗██║██╔██╗ ██║██║  ███╗    █████╗  ███████║██║     █████╗
██╔══██║██║   ██║██║   ██║██║   ██║██║██║╚██╗██║██║   ██║    ██╔══╝  ██╔══██║██║     ██╔══╝
██║  ██║╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝    ██║     ██║  ██║╚██████╗███████╗
╚═╝  ╚═╝ ╚═════╝  ╚═════╝  ╚═════╝ ╚═╝╚═╝  ╚═══╝ ╚═════╝     ╚═╝     ╚═╝  ╚═╝ ╚═════╝╚══════╝

Hugging Face Integration Guide

Focus: Using Hugging Face models, Spaces, Inference Providers, and the official HF MCP server inside Claude Code sessions and automation pipelines.

Overview

Hugging Face hosts 1,000,000+ open-source AI models, datasets, and Spaces. From inside Claude Code you can run models through Inference Providers (a single HF token routing to Together, Cerebras, Fal, Groq, Fireworks and others behind one OpenAI-compatible endpoint), deploy to Spaces, manage datasets, and run Gradio apps — all via the official HF MCP server or the hf CLI. This makes Claude Code a hub for open-model experimentation alongside proprietary APIs.

Here is the big picture — the Hub sits at the center, and Claude Code reaches it through two friendly paths that feed straight into your app.

flowchart LR
  Hub["Hugging Face Hub<br/>models · datasets · Spaces"]
  CC["Claude Code"]
  MCP["HF MCP server"]
  CLI["hf CLI"]
  Inf["Inference Providers"]
  Local["Local weights<br/>download · offline"]
  App["Your app"]
  CC --> MCP
  CC --> CLI
  MCP --> Hub
  CLI --> Hub
  Hub -->|"serverless"| Inf
  Hub -->|"download"| Local
  Inf --> App
  Local --> App

Official Documentation

Resource URL
HF Hub Docs https://huggingface.co/docs/hub
Inference Providers https://huggingface.co/docs/inference-providers
HF MCP Server https://huggingface.co/docs/hub/en/hf-mcp-server
huggingface_hub Python https://huggingface.co/docs/huggingface_hub
Gradio https://www.gradio.app/docs
Transformers https://huggingface.co/docs/transformers
Gradio Spaces https://huggingface.co/docs/hub/spaces-sdks-gradio
Spaces Config Reference https://huggingface.co/docs/hub/spaces-config-reference
MCP Course https://huggingface.co/learn/mcp-course

MCP Server Setup

Hugging Face hosts an official MCP server at https://huggingface.co/mcp. It exposes Hub tools plus any Gradio Space as an additional tool.

# Add HF MCP server to Claude Code (HTTP transport)
claude mcp add --transport http hf-mcp-server \
  https://huggingface.co/mcp \
  -H "Authorization: Bearer ${HF_TOKEN}"

.mcp.json Configuration

{
  "mcpServers": {
    "huggingface": {
      "type": "http",
      "url": "https://huggingface.co/mcp",
      "headers": {
        "Authorization": "Bearer ${HUGGING_FACE_HUB_TOKEN}"
      }
    }
  }
}

Built-in MCP Tools

Tool Description
search_models Search the Hub for models by task, framework, or name
get_model_info Get metadata, card, and usage info for a model
list_datasets Browse and search datasets
inference Run inference on any Inference Providers-compatible model
list_spaces Browse Gradio Spaces
run_space Call a Gradio Space as a tool
create_repo Create a new model/dataset/space repo
upload_file Upload files to a Hub repo

Community alternative

For running Spaces locally there is a community option, npx -y @llmindset/mcp-hfspace. It is no longer the canonical path — prefer the official HF MCP server above.


CLI Integration (hf)

The unified hf CLI (shipped with huggingface_hub) is the current standard. New docs and examples use hf.

Installation + auth

pip install -U huggingface_hub
hf auth login                 # interactive; stores token at ~/.cache/huggingface/token
hf auth login --token $HF_TOKEN   # non-interactive (CI)
hf auth whoami                # confirm the active account

Key commands

# Search & inspect
hf download meta-llama/Llama-3.3-70B-Instruct      # pull weights/files to the local cache
hf upload <user>/my-model ./model-dir              # push a folder to a repo
hf repos create my-model --repo-type model         # create a model/dataset/space repo

# Run a quick chat inference from the shell (Inference Providers)
python -c "
import os
from huggingface_hub import InferenceClient
c = InferenceClient(token=os.environ['HF_TOKEN'])
print(c.chat.completions.create(
    model='openai/gpt-oss-120b',
    messages=[{'role':'user','content':'Say jambo in one word.'}],
).choices[0].message.content)
"

Prefer hf auth login and hf download going forward.


Inference Providers (serverless)

Since 2025, Hugging Face's serverless inference is Inference Providers: one HF token, a single OpenAI-compatible router (https://router.huggingface.co/v1), and automatic routing to best-in-class providers. There is no markup on provider rates, and PRO accounts get included monthly credits.

sequenceDiagram
  participant App as "Your code"
  participant Router as "router.huggingface.co"
  participant Prov as "Provider (Together / Cerebras / Fal / …)"
  App->>Router: "POST /v1/chat/completions (HF token)"
  Router->>Prov: "route by policy (auto / cheapest / named)"
  Prov-->>Router: "tokens"
  Router-->>App: "OpenAI-shaped response"

Providers today (18, per the docs sidebar): Baseten, Cerebras, Cohere, DeepInfra, Fal AI, Featherless AI, Fireworks, Groq, HF Inference, Novita, Nscale, OVHcloud, Public AI, Replicate, Scaleway, Together, WaveSpeed AI, Z.ai. The roster shifts — check hf.co/docs/inference-providers for the live list.

Provider routing — append a policy or provider suffix to the model id:

Suffix Meaning
(none) / :fastest Default. Highest throughput (tokens/sec) available.
:cheapest Lowest price per output token.
:preferred First available in your order at hf.co/settings/inference-providers.
:together, :cerebras, … Force a specific provider.

Python (huggingface_hub)

pip install huggingface_hub
hf auth login   # read token from hf.co/settings/tokens
import os
from huggingface_hub import InferenceClient

client = InferenceClient(token=os.environ["HF_TOKEN"])

# Chat completion — provider defaults to "auto" (fastest)
completion = client.chat.completions.create(
    model="openai/gpt-oss-120b",
    messages=[{"role": "user", "content": "Explain async/await in one sentence."}],
)
print(completion.choices[0].message.content)

# Force a provider for cost/latency control
completion = client.chat.completions.create(
    model="deepseek-ai/DeepSeek-R1",
    provider="novita",   # DeepSeek-R1's live provider; check the model page for options
    messages=[{"role": "user", "content": "Habari!"}],
)

JavaScript / TypeScript (@huggingface/inference)

npm install @huggingface/inference
import { InferenceClient } from "@huggingface/inference";

const client = new InferenceClient(process.env.HF_TOKEN);

const chat = await client.chatCompletion({
  model: "openai/gpt-oss-120b",
  messages: [{ role: "user", content: "What is a transformer?" }],
  // provider defaults to "auto"; set provider: "together" to pin one
});
console.log(chat.choices[0].message.content);

Drop-in OpenAI replacement (chat only)

Swap the base URL and keep your existing OpenAI client:

import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://router.huggingface.co/v1",
  apiKey: process.env.HF_TOKEN,
});

const completion = await client.chat.completions.create({
  model: "deepseek-ai/DeepSeek-R1:cheapest",
  messages: [{ role: "user", content: "Hello!" }],
});

REST (curl)

curl https://router.huggingface.co/v1/chat/completions \
  -H "Authorization: Bearer $HF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-oss-120b:fastest",
    "messages": [{"role": "user", "content": "How many Gs in huggingface?"}],
    "stream": false
  }'

GET https://router.huggingface.co/v1/models lists every available model with per-provider pricing, context length, latency, and throughput — handy for building a model picker.

The OpenAI-compatible endpoint is chat-only. For text-to-image, embeddings, or speech, use the inference clients (client.text_to_image(...), client.feature_extraction(...), etc.).


Pricing breakdown

Verified against https://huggingface.co/pricing on 2026-08-23. Inference Providers add no markup on the underlying provider's per-token rate; GET /v1/models shows live rates.

Account plans

Plan Price What you get
Free $0 Hub access, a generous Inference Providers free tier, CPU-Basic & ZeroGPU Spaces
PRO $9 / mo 20× included inference credits, 8× ZeroGPU quota + highest queue priority, 10× private storage, PRO badge
Team $20 / user / mo Org features, SSO, central billing, included org credits
Enterprise $50 / user / mo Advanced security, audit logs, dedicated support, data residency

Inference Endpoints (dedicated GPUs, scale-to-zero)

Hardware Hourly (per card, AWS; scales with count/region)
CPU (Intel Sapphire Rapids) from $0.03
GPU T4 from $0.50
GPU L4 from $0.80
GPU L40S from $1.80
GPU A100 from $2.50
GPU H100 from $4.50
GPU H200 from $5.00
GPU B200 from $9.25

Spaces hardware

Flavor Hourly
CPU Basic Free
ZeroGPU Free (daily quota: 5 min free / 40 min PRO)
CPU Upgrade $0.03
GPU T4 (small / medium) $0.40 / $0.60
GPU L4 from $0.80
GPU A10G from $1.00
GPU A100 $2.50 – $20.00

ZeroGPU hardware is now the NVIDIA RTX Pro 6000 Blackwell (no longer H200): size large (default) is half a card / 48 GB at 1× quota cost, xlarge is the full card / 96 GB at 2×. Quota is daily — 5 min free, 40 min PRO/Team, 60 min Enterprise — extensible past the cap with pre-paid credits at $1 / 10 min of GPU time. ZeroGPU is Gradio-SDK-only.

Storage

$12 / TB / mo (public) to $18 / TB / mo (private); volume discounts to $8–$10 / TB / mo at 50 TB+, 200 TB+, 500 TB+.

HF-open vs frontier (the codeAmani calculus)

For low-to-mid volume, Inference Providers on a PRO plan is often cheaper than a frontier API and gives you open weights you can later self-host. Pin :cheapest for batch jobs, :fastest for interactive UX. When traffic is steady and high, a dedicated Inference Endpoint (scale-to-zero) beats per-token pricing. For free public demos, a ZeroGPU Space runs a real GPU at no hourly cost.


Deploy a Space

A Space is just a Git repo that Hugging Face builds and runs for you. Pick the Gradio SDK and three files do the work: README.md (a YAML config block tells the runtime what to build), app.py (your Gradio interface), and requirements.txt (deps the runtime installs). Push the repo and your app is live at https://huggingface.co/spaces/<user>/<space>.

Here is the shape of it — Claude Code assembles the three files, pushes once, and the Space build serves your app.

flowchart LR
  CC["Claude Code"]
  Files["README.md · app.py<br/>requirements.txt"]
  Repo["Space repo<br/>git on the Hub"]
  Build["Space build<br/>install deps · launch"]
  Live["Live app<br/>App tab"]
  CC --> Files
  Files -->|"hf upload"| Repo
  Repo --> Build
  Build --> Live

1. The three files

README.md — the YAML block at the top is the Space config (sdk: gradio initializes the latest Gradio; pin sdk_version for reproducible builds):

---
title: Swahili Helper
emoji: 🇰🇪
colorFrom: green
colorTo: blue
sdk: gradio
sdk_version: 6.25.0
app_file: app.py
pinned: false
short_description: Swahili chat demo
---

# Swahili Helper
A small Gradio demo running on Hugging Face Spaces.

app.py — a minimal Gradio interface (the runtime runs app_file automatically):

import gradio as gr

def greet(name: str) -> str:
    return f"Habari, {name}!"

demo = gr.Interface(fn=greet, inputs="text", outputs="text", title="Swahili Helper")

if __name__ == "__main__":
    demo.launch()

requirements.txt — the Spaces runtime installs these on build:

gradio

2. Create + push from the CLI

# Create the Space repo (Gradio SDK) — one time
hf repos create swahili-helper --repo-type space --sdk gradio

# Upload the whole folder (README.md, app.py, requirements.txt) in one commit
hf upload <user>/swahili-helper ./swahili-helper .

…or with the Python SDK

Useful inside a Claude Code automation that generates a Space programmatically:

from huggingface_hub import HfApi

api = HfApi(token=os.environ["HUGGING_FACE_HUB_TOKEN"])

api.create_repo(
    repo_id="<user>/swahili-helper",
    repo_type="space",
    space_sdk="gradio",
    exists_ok=True,
)

api.upload_folder(
    repo_id="<user>/swahili-helper",
    repo_type="space",
    folder_path="./swahili-helper",  # contains README.md, app.py, requirements.txt
)

After the push, the App tab shows the build logs and then the running app.

Gotcha: pin sdk_version

If you leave sdk_version out of the YAML block, Spaces builds against the latest Gradio on every rebuild. A breaking Gradio release can then silently break a Space that worked yesterday — pin sdk_version (e.g. 6.25.0, current major is Gradio 6) so builds stay reproducible. Other notes: Spaces default to free cpu-basic hardware (set GPU flavors via the Settings tab, not the YAML — suggested_hardware is only a hint for users who duplicate your Space); ZeroGPU Spaces are Gradio-SDK-only; and never commit your HF_TOKEN into app.py — add it under Settings → Variables and secrets and read it with os.environ.


Environment Variables

# Required
HUGGING_FACE_HUB_TOKEN=hf_...

# Aliases (all work)
HF_TOKEN=hf_...
HUGGINGFACE_TOKEN=hf_...

# Cache directory (optional)
HF_HOME=~/.cache/huggingface
HF_HUB_CACHE=~/.cache/huggingface/hub

# Use local files only (offline mode)
TRANSFORMERS_OFFLINE=1
HF_HUB_OFFLINE=1

Automation Workflows

Claude Code Hook: Auto-summarize Model Cards

.claude/settings.json:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": ".*huggingface\\.co.*",
        "hooks": [
          {
            "type": "command",
            "command": "python scripts/fetch-model-card.py"
          }
        ]
      }
    ]
  }
}

Slash Command: Run HF Inference

.claude/commands/hf-infer.md:

Run Hugging Face inference on model $ARGUMENTS using the InferenceClient.

Use the Bash tool to execute:
```bash
python -c "
from huggingface_hub import InferenceClient
import os, sys
client = InferenceClient(token=os.environ['HF_TOKEN'])
model, *prompt_parts = '$ARGUMENTS'.split(' ', 1)
prompt = prompt_parts[0] if prompt_parts else 'Hello'
print(client.text_generation(prompt, model=model, max_new_tokens=200))
"
```

Usage: /project:hf-infer mistralai/Mistral-7B-v0.1 Explain async/await

CI/CD: Model Evaluation Pipeline

# .github/workflows/evaluate-model.yml
name: Evaluate Fine-tuned Model
on:
  push:
    paths: ['models/**']

jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install huggingface_hub evaluate datasets
      - name: Run evaluation
        env:
          HUGGING_FACE_HUB_TOKEN: ${{ secrets.HF_TOKEN }}
        run: python scripts/evaluate.py --model ${{ github.sha }}
      - name: Push results to Hub
        run: python scripts/push-results.py

transformers.js — models in the browser / on the edge

@huggingface/transformers (transformers.js) runs models client-side via WebGPU/WASM — no server, no per-call cost, and it works offline after the first load. For codeAmani this is the low-bandwidth / intermittent-connectivity play: ship a small embedding or classification model to the device and skip the round-trip entirely on 2G/3G.

npm install @huggingface/transformers
import { pipeline } from "@huggingface/transformers";

// Lazy-load a tiny model once; runs entirely in the browser tab thereafter.
const classify = await pipeline("sentiment-analysis");
const out = await classify("M-Pesa payment received, asante!");
// → [{ label: "POSITIVE", score: 0.99… }]

Good fits: on-device sentiment/intent, semantic search over a small local corpus, redaction before a network call. Heavy generation still belongs on Inference Providers or an Endpoint.


Apply HF to the rest of the stack

Hugging Face is not an island — it slots into the other codeAmani tools:

import { InferenceClient } from "@huggingface/inference";
const hf = new InferenceClient(process.env.HF_TOKEN);
const vector = await hf.featureExtraction({
  model: "intfloat/multilingual-e5-large",
  inputs: "Bei ya unga ni shilingi ngapi?",
});
// → number[]  → upsert into pgvector / Pinecone

Common Use Cases

Use Case Approach
Open-source model inference InferenceClient or HF MCP inference tool
Dataset exploration MCP list_datasets → get_dataset_info
Space deployment hf upload + Gradio launch()
Model fine-tuning Upload training data, trigger AutoTrain via API
Embedding generation sentence-transformers via Inference Providers
Image generation SDXL via client.text_to_image()

This workspace has the huggingface-skills plugin installed — reach for these instead of hand-rolling:

Skill Use it for
huggingface-skills:hf-cli Scripting the hf CLI
huggingface-skills:huggingface-spaces / :huggingface-gradio Building & shipping Spaces / Gradio apps
huggingface-skills:huggingface-zerogpu ZeroGPU-backed demos (free GPU on PRO)
huggingface-skills:huggingface-lora-space-builder LoRA fine-tune + Space in one go
huggingface-skills:trl-training / :train-sentence-transformers Fine-tuning LLMs / embedders
huggingface-skills:transformers-js Browser/edge inference
huggingface-skills:huggingface-datasets Dataset loading & curation

Troubleshooting

Issue Fix
401 Unauthorized Re-login: hf auth login or check HF_TOKEN
Model loading slowly Use InferenceClient (serverless); avoid cold starts
Gated model access Accept terms at huggingface.co/model-name first
MCP server not connecting Verify token in headers; try curl https://huggingface.co/mcp
Out of memory locally Use Inference Providers instead of loading weights locally

Official docs:

Infisical Integration Guide

Technology: infisical · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/infisical

Insight:

Infisical centralizes secrets so you stop scattering keys across .env.local files — a Machine Identity fetches them at runtime, leaving only two bootstrap secrets behind. Open-source and self-hostable (data residency for KDPA), and infisical scan in CI catches leaked keys before they ship.

██╗███╗   ██╗███████╗██╗███████╗██╗ ██████╗ █████╗ ██╗
██║████╗  ██║██╔════╝██║██╔════╝██║██╔════╝██╔══██╗██║
██║██╔██╗ ██║█████╗  ██║███████╗██║██║     ███████║██║
██║██║╚██╗██║██╔══╝  ██║╚════██║██║██║     ██╔══██║██║
██║██║ ╚████║██║     ██║███████║██║╚██████╗██║  ██║███████╗
╚═╝╚═╝  ╚═══╝╚═╝     ╚═╝╚══════╝╚═╝ ╚═════╝╚═╝  ╚═╝╚══════╝

Infisical Integration Guide

Focus: Centralize codeAmani's secrets (Anthropic/Gemini keys, Daraja/M-Pesa creds, DB URLs) in one open-source, self-hostable platform instead of scattered .env.local files. Fetch at runtime via a Machine Identity, inject locally with infisical run, and catch leaks before they ship with infisical scan.

Overview

Infisical is an open-source secret-management platform. It replaces ad-hoc .env sprawl with a single source of truth organized by project → environment → folder path. Three ways codeAmani uses it:

Auth uses a Machine Identity + Universal Auth: exchange a clientId/clientSecret for a short-lived token. Those two bootstrap secrets are the only thing that still lives in .env.local / Vercel env — everything else moves into Infisical.

Here is the core idea at a glance — your app carries only two bootstrap secrets and fetches the rest at runtime:

flowchart LR
  A["App at runtime"] -->|"clientId + clientSecret<br/>from .env.local"| B["Machine Identity<br/>Universal Auth"]
  B -->|"short-lived token"| C["Infisical platform<br/>project · environment · path"]
  C -->|"secret value"| A
  A -->|"uses key"| D["Anthropic · Daraja<br/>Supabase · Cloudflare"]

Official Documentation

Resource URL
Getting started https://infisical.com/docs/documentation/getting-started/introduction
Node SDK https://infisical.com/docs/sdks/languages/node
CLI overview https://infisical.com/docs/cli/overview
Universal Auth (machine identity) https://infisical.com/docs/documentation/platform/identities/universal-auth
GitHub (self-host) https://github.com/Infisical/infisical

1. Bootstrap credentials

Create a Machine Identity in the Infisical dashboard, attach it to your project, and copy its Universal Auth clientId + clientSecret. These two are the bootstrap secrets:

INFISICAL_CLIENT_ID=...        # the ONLY secrets that stay in .env.local / Vercel env
INFISICAL_CLIENT_SECRET=...

2. SDK — fetch secrets at runtime (Node)

npm install @infisical/sdk    # current major is v5.x (from the node-sdk-v2 repo)
import { InfisicalSDK } from "@infisical/sdk";

// Cloud default; pass { siteUrl } for a self-hosted instance
const client = new InfisicalSDK();

await client.auth().universalAuth.login({
  clientId: process.env.INFISICAL_CLIENT_ID!,
  clientSecret: process.env.INFISICAL_CLIENT_SECRET!,
});

const secret = await client.secrets().getSecret({
  secretName: "DARAJA_CONSUMER_SECRET",
  projectId: "proj_abc123",
  environment: "production",
  secretPath: "/mpesa",          // optional folder
  expandSecretReferences: true,
});
console.log(secret.secretValue);

getSecret throws on a missing key (StatusCode=404 Secret not found) — handle it rather than silently falling back, so a misconfigured environment fails loudly at startup.

SDK v5 note: getSecret / listSecrets accept viewSecretValue (default true). If you pass viewSecretValue: false, secretValue comes back masked as <hidden-by-infisical> — leave it at the default when you actually need the value.

3. CLI — local dev + leak scanning

The CLI gives you two wins — secrets injected into dev without ever touching disk, and a scanner that catches leaks before they ship:

flowchart TD
  A["infisical login + init<br/>links repo to project · env"] --> B["infisical run -- npm run dev"]
  B -->|"inject as env vars<br/>nothing written to disk"| C["dev server child process"]
  A --> D["infisical scan"]
  D --> E{"Leaked secret in<br/>code or git history?"}
  E -->|"yes"| F["fail pre-commit · CI"]
  E -->|"no"| G["safe to ship"]
npm install -g @infisical/cli
infisical login          # interactive
infisical init           # link the repo to a project/environment

# Inject secrets as env vars into the dev server (nothing written to disk):
infisical run -- npm run dev

# Pre-commit / CI: scan code + git history for leaked secrets
infisical scan

4. CI/CD — secrets in GitHub Actions

In CI you don't run infisical login interactively — instead the official Infisical/secrets-action authenticates a Machine Identity and injects the project's secrets into the job as env vars. Store only the two bootstrap values (client-id/client-secret) as GitHub Actions secrets; everything else stays in Infisical.

flowchart LR
  A["GitHub Actions job"] -->|"client-id + client-secret<br/>from GH secrets"| B["Infisical secrets-action<br/>Universal Auth"]
  B -->|"short-lived token"| C["Infisical platform<br/>project · env · path"]
  C -->|"export-type env"| D["secrets as env vars<br/>in later steps"]
  D --> E["build · deploy · migrate"]
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Authenticate via Machine Identity (Universal Auth) and inject secrets
      # as env vars for every subsequent step in this job.
      - uses: Infisical/secrets-action@v1.0.16
        with:
          method: "universal"                              # default
          client-id: ${{ secrets.INFISICAL_CLIENT_ID }}    # the only two GH secrets you need
          client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
          project-slug: "your-project-slug"
          env-slug: "production"                            # dev | staging | production
          secret-path: "/"                                 # default; e.g. "/mpesa"
          domain: "https://app.infisical.com"              # change for a self-hosted instance

      # Secrets are now plain env vars — reference them like any other.
      - name: Deploy
        run: |
          echo "Deploying with DARAJA_CONSUMER_KEY=${DARAJA_CONSUMER_KEY:+set}"
          npm run deploy

Inputs above are the verified Universal Auth keys; export-type defaults to env. Set export-type: file (with file-output-path) instead if a step needs an on-disk .env. OIDC (method: oidc, identity-id) is also supported and removes the long-lived client-secret entirely — prefer it once your CI provider trust is configured.

Gotcha — least-privilege identity scope: create a separate Machine Identity per environment and grant it read-only access to only the project/path that workflow needs (e.g. the production env at /). A CI identity scoped to every project becomes a single key that can exfiltrate your entire secret store if the client-secret GH secret leaks. Rotate the client-secret on a schedule and keep the identity off any path it doesn't read.

codeAmani notes

Official docs:

Lighthouse Monitoring Dashboard & Custom SEO Plugin

Technology: lighthouse · Category: monitoring · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/lighthouse

Insight:

Lighthouse gives you an automated, repeatable way to measure and improve Performance, Accessibility, Best Practices, and SEO (the PWA category was retired in Lighthouse 12). Run it on every deploy with custom plugins for your domain (e.g. KSO index presence, county geo signals, structured School data). The dashboard here lets you pick a site and instantly see scores + metrics + history. Use the plugin system to ship your own audits as a shareable node module. Grounded in official Chrome + Google Search docs; reviewed 2026-08-23 against lighthouse 13.4.

██╗     ██╗ ██████╗ ██╗  ██╗████████╗██╗  ██╗ ██████╗ ██╗   ██╗███████╗███████╗
██║     ██║██╔════╝ ██║  ██║╚══██╔══╝██║  ██║██╔═══██╗██║   ██║██╔════╝██╔════╝
██║     ██║██║  ███╗███████║   ██║   ███████║██║   ██║██║   ██║███████╗█████╗  
██║     ██║██║   ██║██╔══██║   ██║   ██╔══██║██║   ██║██║   ██║╚════██║██╔══╝  
███████╗██║╚██████╔╝██║  ██║   ██║   ██║  ██║╚██████╔╝╚██████╔╝███████║███████╗
╚══════╝╚═╝ ╚═════╝ ╚═╝  ╚═╝   ╚═╝   ╚═╝  ╚═╝ ╚═════╝  ╚═════╝ ╚══════╝╚══════╝

Lighthouse Monitoring Dashboard & Custom SEO Plugin

Focus: Run Google Lighthouse on demand, pick from your sites, see full scores + Core Web Vitals + custom domain audits. Includes a production-ready custom Lighthouse plugin you can publish to npm. Everything you need for continuous performance + SEO monitoring.

Why Lighthouse in 2026

Lighthouse is the official automated auditor from the Chrome team. It produces the numbers that power:

It now covers four scored categories — the PWA category was removed in Lighthouse 12 (May 2024); installability/service-worker checks moved to the Chrome DevTools Application panel and are no longer scored by Lighthouse:

Category What it checks Why it matters for this stack
Performance FCP, Speed Index, LCP, TBT, CLS (lab) User experience + SEO
Accessibility ARIA, contrast, labels, focus Compliance + reach
Best Practices Security, modern APIs, console errors Maintainability
SEO Meta, titles, robots, structured data, mobile Direct ranking impact

Performance score weights (Lighthouse 10–13)

The Performance score is a weighted blend of five lab metrics — these weights have not changed since Lighthouse 10 (verified against the official scoring doc, 2026-08-23):

Metric Weight
Total Blocking Time (TBT) 30%
Largest Contentful Paint (LCP) 25%
Cumulative Layout Shift (CLS) 25%
First Contentful Paint (FCP) 10%
Speed Index (SI) 10%

Score bands: 90–100 green (good), 50–89 orange, 0–49 red. Note that INP is not part of the lab Performance score — it needs a real user interaction, so it surfaces only as field data (CrUX / PageSpeed Insights), not from a cold lab run.

Core Web Vitals thresholds ("good" measured at the 75th percentile)

Metric Good Needs improvement Poor
LCP — loading ≤ 2.5 s ≤ 4.0 s > 4.0 s
INP — interactivity ≤ 200 ms ≤ 500 ms > 500 ms
CLS — visual stability ≤ 0.1 ≤ 0.25 > 0.25

INP replaced FID as the responsiveness Core Web Vital on 12 March 2024; FID is fully retired. These are the thresholds the dashboard's mobile CWV panel is graded against.

Versions & tooling (verified 2026-08-23)

Package Current Notes
lighthouse 13.4.1 requires Node ≥ 22.19 for the full local runner
chrome-launcher 1.2.1 launches headless Chrome for the --full path
@lhci/cli 0.15.1 the maintained Lighthouse CI package (npm i -D @lhci/cli)
PageSpeed Insights API v5 https://www.googleapis.com/pagespeedonline/v5/runPagespeed — set PSI_API_KEY for higher quota

The PSI (default) path only needs Node 18+ (global fetch); the --full Chrome path pulls in lighthouse + chrome-launcher and therefore Node ≥ 22.19.

The Interactive Dashboard (right here)

Use the Site Selector below to pick a known site (e.g. kenyanschools.org or any of your deployed projects). Click Run Audit to simulate (or connect to) a fresh Lighthouse run. You'll see:

The implementation is a self-contained React component that matches the rest of the tech-stack portal (glass, fresh green accents, tilt cards). In a real setup you would wire the "Run" button to a server route that actually invokes lighthouse + your plugin.

Lighthouse Plugin System

Lighthouse is extensible. A plugin is just a small npm package that adds new audits and a new category to the report.

See the full official guide: https://github.com/GoogleChrome/lighthouse/blob/main/docs/plugins.md

Minimal plugin structure (copy-paste ready)

// lighthouse-plugin-kenyan-schools/plugin.js
export default {
  audits: [
    { path: 'lighthouse-plugin-kenyan-schools/audits/school-index-present.js' },
  ],
  category: {
    title: 'Kenyan Schools SEO',
    auditRefs: [{ id: 'school-index-present', weight: 1 }],
  },
};

Example custom audit (checks for KSO-XXXX):

// audits/school-index-present.js
import { Audit } from 'lighthouse';

class SchoolIndexPresent extends Audit {
  static get meta() {
    return {
      id: 'school-index-present',
      title: 'Page contains unique KSO school index',
      requiredArtifacts: ['MainDocumentContent'],
    };
  }
  static audit({ MainDocumentContent }) {
    const has = /KSO-\d{4}/i.test(MainDocumentContent);
    return { score: has ? 1 : 0, numericValue: has ? 1 : 0 };
  }
}
export default SchoolIndexPresent;

Usage (local dev):

npx lighthouse https://kenyanschools.org \
  --plugins=lighthouse-plugin-kenyan-schools \
  --only-categories=lighthouse-plugin-kenyan-schools,seo

Publish to npm as lighthouse-plugin-kenyan-schools and anyone can use it.

Pre-built Custom Audits (included)

The dashboard demonstrates these audits that ship with the example plugin:

Site Selector + Stats

The live module below lets you:

  1. Select a site from your portfolio (or type a custom URL)
  2. See the latest Lighthouse numbers
  3. "Re-run" to get fresh (demo) numbers

Connect the Run button to a real endpoint (the original internal run-audit.js + express server from the monorepo is a perfect starting point).

Live Dashboard (deployed)

This entry ships a live interactive dashboard, not a mock:

Run the same logic from the CLI: node lighthouse/examples/run-audit.js https://kenyanschools.org [--full].

Next.js / Vercel Integration Pattern

// .github/workflows/lighthouse.yml (example)
- name: Lighthouse
  uses: treosh/lighthouse-ci-action@v12
  with:
    urls: |
      https://kenyanschools.org
      https://kenyanschools.org/schools
    configPath: ./lighthouserc.json

lighthouserc.json can load your custom plugin.

Copy-Paste Templates

See the examples/ directory in this tech-stack entry for:

All files are ready to drop into any project.


Status: Fresh as of 2026-08-23 (lighthouse 13.4, @lhci/cli 0.15, PSI API v5). This entry ships both the measurement tool (dashboard) and the extensibility story (plugin + custom audits) that the rest of the CodeAmani stack relies on for performance gates.

Official docs:

Local Databases (WSL + Docker) Guide

Technology: local-database · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/local-database

Insight:

This is the local-dev mirror of the Supabase/Neon production databases — the same Postgres major version, the same extensions, the same RLS policies, running as a throwaway container inside WSL Ubuntu. The trade-off is honest: you get an offline, zero-cost, reset-in-two-seconds database, but you do not get Supabase's Auth/Storage/Realtime or Neon's branching, so anything that depends on those still needs a cloud branch. The rule that makes it safe: a local container is a disposable copy of production's shape, never a place to relax production's rules — POSTGRES_HOST_AUTH_METHOD=trust and 127.0.0.1-only port binds are the two knobs that decide whether "just for dev" stays just for dev.

██╗      ██████╗  ██████╗ █████╗ ██╗
██║     ██╔═══██╗██╔════╝██╔══██╗██║
██║     ██║   ██║██║     ███████║██║
██║     ██║   ██║██║     ██╔══██║██║
███████╗╚██████╔╝╚██████╗██║  ██║███████╗
╚══════╝ ╚═════╝  ╚═════╝╚═╝  ╚═╝╚══════╝

██████╗  █████╗ ████████╗ █████╗ ██████╗  █████╗ ███████╗███████╗
██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔════╝
██║  ██║███████║   ██║   ███████║██████╔╝███████║███████╗█████╗
██║  ██║██╔══██║   ██║   ██╔══██║██╔══██╗██╔══██║╚════██║██╔══╝
██████╔╝██║  ██║   ██║   ██║  ██║██████╔╝██║  ██║███████║███████╗
╚═════╝ ╚═╝  ╚═╝   ╚═╝   ╚═╝  ╚═╝╚═════╝ ╚═╝  ╚═╝╚══════╝╚══════╝

Local Databases (WSL + Docker) Guide

Focus: Running Postgres, MySQL/MariaDB, Redis, MongoDB and pgvector as containers inside WSL Ubuntu — pinned docker run one-liners, one compose.yaml for the whole local stack with named volumes, connecting from the Windows host and from Next.js, and clean teardown. Grounded in docs.docker.com and the official image docs; tags checked against Docker Hub on 2026-08-23.

Overview

codeAmani ships production data to [[supabase]] (Postgres + RLS) or [[neon]] (serverless Postgres with branching). Neither is a good place to run a migration you're not sure about at 2am on a hotel Wi-Fi. A local containerized database is the mirror: same engine, same major version, same extensions, zero network, zero bill, and docker compose down -v to start over.

The whole thing rides on the stack the [[docker]] guide already sets up — Docker Desktop on the WSL 2 backend, or Docker Engine installed directly in your Ubuntu distro. Either way the daemon runs against a real Linux kernel, so postgres:18 locally is byte-for-byte the postgres:18 that Supabase and Neon run.

Local container Supabase / Neon
Cost free per-project / per-compute
Works offline ✅ ❌
Reset to empty down -v (~2s) branch reset / re-provision
RLS + pgvector ✅ (identical Postgres) ✅
Auth / Storage / Realtime ❌ ✅ (Supabase)
Branching per preview deploy ❌ ✅ (Neon)
Where the data actually lives a Docker named volume managed, backed up
flowchart LR
  W["Windows 11 host<br/>Next.js dev · psql · TablePlus"]
  subgraph WSL["WSL 2 · Ubuntu"]
    D["Docker Engine<br/>(Desktop integration or apt)"]
    subgraph NET["compose network 'app-net'"]
      PG["postgres:18.6-trixie<br/>:5432"]
      RD["redis:8.10.1-alpine<br/>:6379"]
      MG["mongo:8.0.29-noble<br/>:27017"]
    end
    V[("named volumes<br/>pgdata · redisdata · mongodata")]
  end
  W -->|"127.0.0.1:5432<br/>WSL localhost forwarding"| PG
  D --- NET
  PG --- V
  RD --- V
  MG --- V
  PG -.->|"same engine, same<br/>schema, same RLS"| PROD["Supabase / Neon<br/>production"]

Official Documentation

Resource URL
Docker Desktop WSL 2 backend https://docs.docker.com/desktop/features/wsl/
Install Docker Engine on Ubuntu https://docs.docker.com/engine/install/ubuntu/
Run Docker without sudo (post-install) https://docs.docker.com/engine/install/linux-postinstall/
docker container run reference https://docs.docker.com/reference/cli/docker/container/run/
Compose file reference https://docs.docker.com/reference/compose-file/
Volumes https://docs.docker.com/engine/storage/volumes/
Compose startup order (depends_on) https://docs.docker.com/compose/how-tos/startup-order/
Postgres official image https://hub.docker.com/_/postgres
MySQL official image https://hub.docker.com/_/mysql
MariaDB official image https://hub.docker.com/_/mariadb
Redis official image https://hub.docker.com/_/redis
MongoDB official image https://hub.docker.com/_/mongo
pgvector/pgvector image https://hub.docker.com/r/pgvector/pgvector
WSL networking (localhost, mirrored mode) https://learn.microsoft.com/en-us/windows/wsl/networking

1. Get a Docker daemon inside WSL Ubuntu

Two supported paths. Pick one — do not run both.

Install Docker Desktop, then Settings → Resources → WSL integration → enable your Ubuntu distro. The docker and docker compose CLIs then work from inside Ubuntu with no daemon of your own, and published ports land on Windows localhost automatically. See the [[docker]] guide for the full walkthrough.

Path B — Docker Engine straight into Ubuntu (no Desktop)

Run this inside the WSL Ubuntu shell. These are the current commands from docs.docker.com/engine/install/ubuntu/:

# 1. Docker's official GPG key
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# 2. Add the repository to Apt sources
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update

# 3. Install engine + CLI + compose plugin
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# 4. Run docker without sudo (log out / `wsl --shutdown` to pick up the group)
sudo usermod -aG docker $USER

# 5. Verify
docker run --rm hello-world
docker compose version

WSL doesn't boot systemd unless you ask it to. Enable it once so the daemon starts with the distro:

# /etc/wsl.conf inside Ubuntu
sudo tee /etc/wsl.conf <<'EOF'
[boot]
systemd=true
EOF
# then from PowerShell on the host:
#   wsl --shutdown

Without systemd, start the daemon by hand each session with sudo service docker start.

Filesystem rule (same as [[docker]]): keep the project on the Linux filesystem (~/code/...), never /mnt/c/.... Bind-mounting a database data directory from /mnt/c crosses the 9p boundary on every fsync and will make Postgres crawl. Named volumes (below) sidestep this entirely — they live inside the WSL VM's ext4 disk.


2. Environment variables

One .env.local per project. These are local-only development credentials — they still never get committed, because the habit is what protects the production string that eventually sits in the same file.

# .env.local — local containers only
POSTGRES_USER=app
POSTGRES_PASSWORD=devpassword
POSTGRES_DB=appdb
DATABASE_URL=postgresql://app:devpassword@localhost:5432/appdb

REDIS_URL=redis://localhost:6379
MONGODB_URI=mongodb://root:devpassword@localhost:27017/appdb?authSource=admin
MYSQL_ROOT_PASSWORD=devpassword
MYSQL_URL=mysql://app:devpassword@localhost:3306/appdb

3. docker run one-liners (pinned tags)

Every tag below was resolved against Docker Hub on 2026-08-23. Pin the minor version — latest moves under you and a Postgres major bump silently invalidates the data directory.

Postgres 18

docker run -d --name pg \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=devpassword \
  -e POSTGRES_DB=appdb \
  -p 127.0.0.1:5432:5432 \
  -v pgdata:/var/lib/postgresql \
  postgres:18.6-trixie

⚠️ Postgres 18 changed the volume target. For 18 and above the image sets PGDATA=/var/lib/postgresql/18/docker and declares its VOLUME at /var/lib/postgresql. For 17 and below you must still mount at /var/lib/postgresql/data — mounting 17 at /var/lib/postgresql silently writes to an anonymous volume and your data vanishes on re-create. Copy the right line for your major version.

# Postgres 17 and below — note the /data suffix
docker run -d --name pg17 \
  -e POSTGRES_PASSWORD=devpassword \
  -p 127.0.0.1:5432:5432 \
  -v pg17data:/var/lib/postgresql/data \
  postgres:17.11-trixie

Postgres + pgvector

pgvector/pgvector is the official Postgres image with the extension already compiled in — same env vars, same volume rules, same major-version tag scheme. Use it whenever the project touches embeddings (see [[pgvector]]).

docker run -d --name pgv \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=devpassword \
  -e POSTGRES_DB=appdb \
  -p 127.0.0.1:5432:5432 \
  -v pgvdata:/var/lib/postgresql \
  pgvector/pgvector:0.8.6-pg18-trixie

The extension ships in the image but is not enabled in your database until you say so — exactly like Supabase and Neon:

docker exec -it pgv psql -U app -d appdb -c "CREATE EXTENSION IF NOT EXISTS vector;"
docker exec -it pgv psql -U app -d appdb -c "SELECT extversion FROM pg_extension WHERE extname='vector';"

MySQL 9.7 (LTS)

docker run -d --name mysql \
  -e MYSQL_ROOT_PASSWORD=devpassword \
  -e MYSQL_DATABASE=appdb \
  -e MYSQL_USER=app \
  -e MYSQL_PASSWORD=devpassword \
  -p 127.0.0.1:3306:3306 \
  -v mysqldata:/var/lib/mysql \
  mysql:9.7.2

MySQL switched Innovation releases to calendar versioning starting at 26.7.0 (YY.M.P), and mysql:latest now follows that Innovation track. mysql:lts currently resolves to 9.7.2; 8.4 is the previous LTS line. Pin an LTS unless you specifically want Innovation features.

MariaDB 12.3 (LTS)

docker run -d --name mariadb \
  -e MARIADB_ROOT_PASSWORD=devpassword \
  -e MARIADB_DATABASE=appdb \
  -e MARIADB_USER=app \
  -e MARIADB_PASSWORD=devpassword \
  -p 127.0.0.1:3306:3306 \
  -v mariadbdata:/var/lib/mysql \
  mariadb:12.3.2

MariaDB's env vars are MARIADB_* (the MYSQL_* spellings are legacy aliases), and the data directory is still /var/lib/mysql.

Redis 8

Redis runs without persistence and without a password by default. Turn on snapshots explicitly or your local cache evaporates on restart:

docker run -d --name redis \
  -p 127.0.0.1:6379:6379 \
  -v redisdata:/data \
  redis:8.10.1-alpine \
  redis-server --save 60 1 --appendonly yes --loglevel warning

--save 60 1 snapshots if ≥1 write happened in the last 60s; --appendonly yes adds the AOF log. Both write to the VOLUME /data. This is the local stand-in for [[upstash]] Redis — see the [[caching]] guide for what belongs in it.

MongoDB 8.0

docker run -d --name mongo \
  -e MONGO_INITDB_ROOT_USERNAME=root \
  -e MONGO_INITDB_ROOT_PASSWORD=devpassword \
  -e MONGO_INITDB_DATABASE=appdb \
  -p 127.0.0.1:27017:27017 \
  -v mongodata:/data/db \
  mongo:8.0.29-noble

Transactions and change streams require a replica set, even locally — and Prisma's Mongo connector refuses to write without one. A single-node replica set is enough:

docker run -d --name mongo -p 127.0.0.1:27017:27017 -v mongodata:/data/db \
  mongo:8.0.29-noble --replSet rs0 --bind_ip_all
docker exec -it mongo mongosh --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})'

Then use mongodb://localhost:27017/appdb?replicaSet=rs0&directConnection=true. See the [[mongodb]] guide for driver-side detail.

Why 127.0.0.1:PORT:PORT and not -p PORT:PORT

-p 5432:5432 binds 0.0.0.0 inside the WSL VM. With WSL's mirrored networking mode, or a netsh portproxy, or a corporate Wi-Fi that treats the host as LAN-reachable, that is a Postgres with a dev password answering the network. -p 127.0.0.1:5432:5432 binds loopback only and costs nothing. Make it the default.


4. The whole local stack — compose.yaml

One file, one command, named volumes for persistence, healthchecks so nothing races the database. Drop it at the repo root.

# compose.yaml — local dev only. No `version:` key: it is obsolete
# and Compose warns if you include it.
name: appdb-local

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg18-trixie
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: appdb
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql          # pg18+ target — NOT /data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 20s

  redis:
    image: redis:8.10.1-alpine
    restart: unless-stopped
    command: ["redis-server", "--save", "60", "1", "--appendonly", "yes", "--loglevel", "warning"]
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  mongo:
    image: mongo:8.0.29-noble
    restart: unless-stopped
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: devpassword
      MONGO_INITDB_DATABASE: appdb
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongodata:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s

  # Swap in instead of postgres for a MySQL-shaped project.
  # mariadb:
  #   image: mariadb:12.3.2
  #   environment:
  #     MARIADB_ROOT_PASSWORD: devpassword
  #     MARIADB_DATABASE: appdb
  #     MARIADB_USER: app
  #     MARIADB_PASSWORD: devpassword
  #   ports: ["127.0.0.1:3306:3306"]
  #   volumes: [mariadbdata:/var/lib/mysql]
  #   healthcheck:
  #     test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
  #     interval: 10s
  #     retries: 10

volumes:
  pgdata:
  redisdata:
  mongodata:
  # mariadbdata:
docker compose up -d                 # start everything, detached
docker compose ps                    # STATUS column shows (healthy)
docker compose logs -f postgres      # tail one service
docker compose exec postgres psql -U app -d appdb

If your Next.js app also runs as a Compose service, make it wait for a healthy database, not merely a started one:

  web:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      # inside the compose network: service name + CONTAINER port
      DATABASE_URL: postgresql://app:devpassword@postgres:5432/appdb

The port that matters depends on who is asking. From the Windows host or next dev running on the host → localhost:5432 (the published port). From another container on the same Compose network → postgres:5432 (the service name and the container port). Mixing these up is the single most common "connection refused" in a local stack.

Seeding: /docker-entrypoint-initdb.d

Postgres, MySQL, MariaDB and MongoDB all run scripts from /docker-entrypoint-initdb.d in alphabetical order, only on first init of an empty data directory. Postgres takes .sh, .sql, .sql.gz; MongoDB takes .sh and .js (run through mongosh).

-- db/init/001_schema.sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE deliveries (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL,
  rider_phone text NOT NULL,          -- 254XXXXXXXXX
  fare_kes    integer NOT NULL CHECK (fare_kes > 0),
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Mirror production: RLS on locally too, so a missing policy
-- fails on your laptop instead of in Supabase.
ALTER TABLE deliveries ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON deliveries
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

Because they only run on an empty volume, re-seeding means resetting the volume — which is the next section, and is the whole point of the local mirror.


5. Connecting from the Windows host

WSL 2's default NAT mode forwards published container ports to Windows localhost, so once a container publishes 5432 inside Ubuntu, localhost:5432 from Windows just works — psql, TablePlus, DBeaver, Prisma Studio, next dev running natively on Windows, all of it. Docker Desktop's WSL integration does the same thing through its own proxy.

# From Windows PowerShell
psql "postgresql://app:devpassword@localhost:5432/appdb"

# Which distro IP is it actually on? (rarely needed in NAT mode)
wsl.exe hostname -I

If you'd rather have Windows and Linux share one loopback outright, Windows 11 22H2+ supports mirrored networking — put this in C:\Users\<you>\.wslconfig and wsl --shutdown:

[wsl2]
networkingMode=mirrored

Mirrored mode also lets Linux reach Windows servers at 127.0.0.1. Note the trade: mirrored mode makes WSL directly reachable from your LAN, which is exactly why the 127.0.0.1: prefix on every -p above earns its keep.

From Next.js

Use a pooled client held in a module singleton, and cache it on globalThis so Next's dev HMR doesn't open a new pool on every hot reload. Parameterized queries only — never string-interpolate into SQL.

// lib/db.ts — server-only
import { Pool } from "pg";

const globalForDb = globalThis as unknown as { pgPool?: Pool };

export const pool =
  globalForDb.pgPool ??
  new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 10,                    // local container: keep it small
    idleTimeoutMillis: 30_000,
    // Local containers have no TLS. Production (Supabase/Neon) requires it —
    // key off the env, never off a hardcoded `false`.
    ssl: process.env.DATABASE_URL?.includes("localhost")
      ? false
      : { rejectUnauthorized: true },
  });

if (process.env.NODE_ENV !== "production") globalForDb.pgPool = pool;

export async function getDeliveries(tenantId: string) {
  const { rows } = await pool.query(
    "SELECT id, rider_phone, fare_kes FROM deliveries WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 50",
    [tenantId],                 // parameterized — $1, not template literals
  );
  return rows;
}

To exercise the RLS policy the way Supabase will, set the tenant on the connection inside a transaction:

export async function withTenant<T>(tenantId: string, fn: (c: import("pg").PoolClient) => Promise<T>) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const out = await fn(client);
    await client.query("COMMIT");
    return out;
  } catch (e) {
    await client.query("ROLLBACK");
    throw e;
  } finally {
    client.release();           // always return it to the pool
  }
}

Verified current client libraries (npm, 2026-08-23): pg 8.23.0, postgres (postgres.js) 3.4.9, mysql2 3.23.4, ioredis 6.0.0, mongodb 7.5.0.


6. Teardown, reset, and inspection

docker compose stop                  # pause; volumes and data survive
docker compose down                  # remove containers + network; volumes SURVIVE
docker compose down -v               # remove volumes too — the true reset
docker compose up -d --force-recreate --pull always

# Single-container equivalents
docker stop pg && docker rm pg
docker volume rm pgdata              # only after the container is gone

# What's on disk?
docker volume ls
docker volume inspect pgdata
docker system df                     # images + volumes + build cache totals
docker system prune -a --volumes     # nuclear: everything unused, all projects
# Back up / restore a local Postgres volume without leaving the container
docker compose exec -T postgres pg_dump -U app -d appdb > backup.sql
docker compose exec -T postgres psql -U app -d appdb < backup.sql

The reset loop — down -v && up -d — is the reason this stack exists. It makes "let me just try the destructive migration" a two-second decision instead of a Neon branch and a prayer.


7. Picking the store

flowchart TD
  A["What are you storing?"] --> B{"Relational, and<br/>prod is Supabase or Neon?"}
  B -->|"yes, plus embeddings"| C["pgvector/pgvector:0.8.6-pg18-trixie"]
  B -->|"yes, plain"| D["postgres:18.6-trixie"]
  B -->|"no"| E{"Shape?"}
  E -->|"ephemeral · counters · rate limits · queue"| F["redis:8.10.1-alpine<br/>local stand-in for Upstash"]
  E -->|"documents · flexible schema"| G["mongo:8.0.29-noble<br/>+ --replSet for transactions"]
  E -->|"legacy MySQL app"| H["mysql:9.7.2 (LTS)<br/>or mariadb:12.3.2 (LTS)"]
  C --> Z["Same schema + RLS as production"]
  D --> Z

codeAmani notes


Troubleshooting

Issue Fix
Cannot connect to the Docker daemon in WSL Path B without systemd — sudo service docker start, or set [boot] systemd=true in /etc/wsl.conf and wsl --shutdown
permission denied ... /var/run/docker.sock sudo usermod -aG docker $USER, then wsl --shutdown to get a fresh login shell
Data gone after docker compose up recreate (Postgres ≤17) Volume mounted at /var/lib/postgresql instead of /var/lib/postgresql/data — writes went to an anonymous volume. Use /data for 17 and below, bare path for 18+
database files are incompatible with server The volume was initialized by a different Postgres major. pg_dump from the old tag, down -v, restore into the new one
port is already allocated A native Windows Postgres/MySQL owns the port. Remap the host side: -p 127.0.0.1:55432:5432
Windows can't reach localhost:5432 after sleep or VPN WSL localhost forwarding wedged — wsl --shutdown from PowerShell, then restart the distro. Or try networkingMode=mirrored
App container gets ECONNREFUSED postgres:5432 It started before the DB was ready — add depends_on: {postgres: {condition: service_healthy}} and a pg_isready healthcheck
App container connects to localhost and fails Inside a container, localhost is that container. Use the Compose service name and the container port
type "vector" does not exist The image ships the extension; the database still needs CREATE EXTENSION vector;. Put it in db/init/001_*.sql
Mongo: Transaction numbers are only allowed on a replica set member Start with --replSet rs0 --bind_ip_all and run rs.initiate(...) once
Redis empty after restart Default Redis persists nothing — pass redis-server --save 60 1 --appendonly yes and mount /data
Postgres crawls / fsync storms Data on /mnt/c via a bind mount. Move to a named volume or a path under ~ inside the distro
the attribute 'version' is obsolete warning Delete the top-level version: key from compose.yaml — it is informative only

Official docs:

Meta for Developers Integration Guide

Technology: meta-developers · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/meta-developers

Insight:

Everything Meta lets you build — WhatsApp, Messenger, Instagram, Login, Marketing — is one Graph API (graph.facebook.com/{version}) wearing different hats. Learn Graph fundamentals (nodes/edges, tokens, app types, webhook HMAC) once and every product becomes familiar. For codeAmani's Kenyan-business work, the killer surface is the WhatsApp Business Platform: it is the dominant chat app in East Africa, and Flows + templates turn a WhatsApp thread into a full booking/ordering app without the customer installing anything.

███╗   ███╗███████╗████████╗ █████╗     ██████╗ ███████╗██╗   ██╗
████╗ ████║██╔════╝╚══██╔══╝██╔══██╗    ██╔══██╗██╔════╝██║   ██║
██╔████╔██║█████╗     ██║   ███████║    ██║  ██║█████╗  ██║   ██║
██║╚██╔╝██║██╔══╝     ██║   ██╔══██║    ██║  ██║██╔══╝  ╚██╗ ██╔╝
██║ ╚═╝ ██║███████╗   ██║   ██║  ██║    ██████╔╝███████╗ ╚████╔╝
╚═╝     ╚═╝╚══════╝   ╚═╝   ╚═╝  ╚═╝    ╚═════╝ ╚══════╝  ╚═══╝

Meta for Developers Integration Guide

Focus — the whole Meta developer surface as one platform: Graph API fundamentals, app creation, the token/permission model, webhooks, and the consumer-facing products (WhatsApp, Messenger, Instagram, Login, Marketing API), capped with a catalog of business build ideas for the Kenyan community. For the deep messaging-only reference (send/template/Twilio-vs-Meta) see the focused whatsapp-business-api guide — this guide is the platform-wide course around it.

Overview

Meta for Developers is a single platform exposed through one base URL — https://graph.facebook.com/{version}/{node-or-edge}. WhatsApp Cloud API, Messenger, Instagram, Marketing API, and Pages are all specialized layers over the Graph. Five things are worth internalizing before you write a line of code:

  1. Everything is the Graph API. Master nodes/edges/fields, versioning, and error codes once; every product reuses them.
  2. App type is a permanent decision. A Business app (WhatsApp, Pages, Marketing) uses access levels (Standard → Advanced via App Review). A Consumer app uses app modes (Dev/Live). You cannot convert one to the other — recreate the app if you chose wrong.
  3. Tokens are a taxonomy, not a single thing. User tokens expire fast; system user tokens are the production credential for server automation.
  4. Webhook HMAC is the security perimeter. Every event is signed with X-Hub-Signature-256 (HMAC-SHA256 of the raw body keyed with your App Secret). Validate with a constant-time compare before parsing.
  5. WhatsApp pricing is per-message (since July 2025) and messaging limits are portfolio-wide (since Oct 2025), starting at 250 unique users / 24h and scaling algorithmically: 250 → 2,000 → 10,000 → 100,000 → unlimited.

Official Documentation

Topic URL
Docs index (product map) https://developers.facebook.com/docs/
Graph API https://developers.facebook.com/docs/graph-api/
Create an App / App Types https://developers.facebook.com/docs/development/create-an-app/
WhatsApp Cloud API https://developers.facebook.com/docs/whatsapp/cloud-api
WhatsApp Flows https://developers.facebook.com/docs/whatsapp/flows
WhatsApp pricing & limits https://developers.facebook.com/docs/whatsapp/pricing
Webhooks https://developers.facebook.com/docs/graph-api/webhooks/getting-started
Messenger Platform https://developers.facebook.com/docs/messenger-platform/
Instagram Platform https://developers.facebook.com/docs/instagram-platform/
Official WhatsApp Node SDK https://whatsapp.github.io/WhatsApp-Nodejs-SDK/
Meta Business SDK (Node) https://github.com/facebook/facebook-nodejs-business-sdk

The platform map

Family Products
Core Graph API, App Development, Webhooks, App Dashboard
Business Messaging WhatsApp Business Platform (Cloud API), WhatsApp Flows, Messenger, Instagram Messaging
Social Pages API, Instagram Platform, Threads API, Sharing, Stories
Identity Facebook Login, Facebook Login for Business
Ads & Commerce Marketing API, Conversions API, Catalog, Commerce Platform
SDKs JS SDK, Android, iOS, whatsapp (Node), Meta Business SDK

Always pin a version (v26.0+, current stable — released 2026-07-29). Unversioned calls default to the oldest supported version — a silent footgun.

Setup — register, create an app, add a product

  1. Register at developers.facebook.com with a Facebook account; land in the App Dashboard.
  2. Create App via the modern use-case flow — a use case auto-attaches the permissions/features/products it needs. You receive an App ID + App Secret (your OAuth client credentials — the secret never ships to a client).
  3. Add Product → WhatsApp → Set up provisions a test WABA, a test phone number (free messages to 5 verified recipients), and the pre-approved hello_world template.

Tokens at a glance

Token Lifetime Use
User access token (short) ~1–2 h Client reads after login
User access token (long) ~60 days Server calls on behalf of a user
App access token Long App config, webhook subscriptions
System user token Configurable / non-expiring Production server automation (WhatsApp standard)
Business integration token Long Multi-tenant SaaS acting on a customer's WABA (via Embedded Signup)

Harden server-to-server calls with appsecret_proof (HMAC-SHA256 of the access token keyed with the App Secret) and enable Require App Secret so a stolen bare token is useless. See examples/graph-api-call.ts.

Quickstart

First Graph API call (official Meta Business SDK, Node)

npm install facebook-nodejs-business-sdk
// Read the node behind a token, then walk an edge. Same pattern for every product.
const adsSdk = require("facebook-nodejs-business-sdk");
adsSdk.FacebookAdsApi.init(process.env.META_SYSTEM_USER_TOKEN);
// e.g. account.read([...]) / account.getCampaigns([...]) for Marketing API

First WhatsApp message (official whatsapp SDK, verified quickstart)

npm install whatsapp
# .env: WA_PHONE_NUMBER_ID=  CLOUD_API_ACCESS_TOKEN=  CLOUD_API_VERSION=v26.0
import WhatsApp from "whatsapp";

const wa = new WhatsApp(Number(process.env.WA_PHONE_NUMBER_ID)); // sender phone-number id

async function sendMessage(recipient) {
  // text() works only inside the 24h service window; use template() to initiate.
  const res = await wa.messages.text({ body: "Habari! Your order has shipped." }, recipient);
  console.log((await res).rawResponse());
}

For raw curl/PowerShell sends, interactive messages, templates, and media, the focused whatsapp-business-api guide has the runnable recipes. This guide owns the platform-wide patterns below.

Key patterns

Webhooks (the universal push channel)

One mechanism delivers inbound WhatsApp/Messenger/Instagram messages, delivery statuses, template-review outcomes, Page events, and more — all as signed HTTPS POSTs. The contract is always:

See examples/webhook-router.ts for a complete queue-first receiver.

WhatsApp Flows — chat that behaves like an app

Flows ship multi-screen native UI (forms, booking, lead-gen) inside the thread. Static Flows (pure Flow JSON) deliver collected data to your webhook on completion; Dynamic Flows add an encrypted data endpoint for live validation/availability. Published Flows can only be deprecated, not deleted. See examples/booking-flow.json for a Kenyan-business intake Flow.

Embedded Signup — onboard other businesses

For Tech Providers/agencies: a Meta-hosted popup (built on Facebook Login for Business) that creates/links a customer's portfolio + WABA + phone number and returns a code your server exchanges for a business token scoped to that customer. This is how you operate WhatsApp for many SMEs from one dashboard.

Build ideas for Kenyan businesses

WhatsApp is the default channel in East Africa, so the highest-leverage builds pair a WhatsApp surface with M-Pesa (see MPESA_PATTERNS.md / daraja-api). A starter catalog — the full list with effort/revenue notes is in reference/build-ideas-kenya.md:

  1. Duka order bot — a WhatsApp Flow catalog + cart; checkout fires an M-Pesa STK Push; utility template confirms inside the free service window.
  2. Boda / delivery dispatch — inbound location message → assign rider → delivery-status templates; rider replies drive the 24h window.
  3. Clinic / salon booking — a booking Flow writes to Neon; reminder utility templates 24h before; reschedule via reply buttons.
  4. Sacco / chama assistant — members check balances and contribution status; authentication templates send OTPs; statements as document messages.
  5. School fees & comms — fee-balance utility templates + an M-Pesa paybill link; broadcast announcements via marketing templates (with opt-out).
  6. Agri price & advisory — daily market-price templates by crop; a Flow captures produce listings; buyers reply to express interest.

Every one of these is a thin WhatsApp/Flows front-end over the codeAmani stack (Next.js + Neon/Supabase + Daraja), which is exactly the build sweet spot.

codeAmani notes

Official docs:

Microsoft Learn (MCP) Integration Guide

Technology: microsoft-learn · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/microsoft-learn

Insight:

The Microsoft Learn MCP Server is a free, remote, unauthenticated connector (https://learn.microsoft.com/api/mcp, generally available since Nov 2025) that grounds Claude Code in live, first-party Azure / .NET / Entra / Microsoft 365 docs — the same "no trained-data guessing" rule as Context7, but Microsoft-specific. In Claude Code the official path is now the microsoft-docs plugin (/plugin install microsoft-docs@microsoft-docs-marketplace), which bundles the MCP server plus three helper skills; then research any Microsoft product with microsoft_docs_search → microsoft_docs_fetch → microsoft_code_sample_search.

███╗   ███╗██╗ ██████╗██████╗  ██████╗ ███████╗ ██████╗ ███████╗████████╗
████╗ ████║██║██╔════╝██╔══██╗██╔═══██╗██╔════╝██╔═══██╗██╔════╝╚══██╔══╝
██╔████╔██║██║██║     ██████╔╝██║   ██║███████╗██║   ██║█████╗     ██║
██║╚██╔╝██║██║██║     ██╔══██╗██║   ██║╚════██║██║   ██║██╔══╝     ██║
██║ ╚═╝ ██║██║╚██████╗██║  ██║╚██████╔╝███████║╚██████╔╝██║        ██║
╚═╝     ╚═╝╚═╝ ╚═════╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝        ╚═╝

██╗     ███████╗ █████╗ ██████╗ ███╗   ██╗
██║     ██╔════╝██╔══██╗██╔══██╗████╗  ██║
██║     █████╗  ███████║██████╔╝██╔██╗ ██║
██║     ██╔══╝  ██╔══██║██╔══██╗██║╚██╗██║
███████╗███████╗██║  ██║██║  ██║██║ ╚████║
╚══════╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝

Microsoft Learn (MCP) Integration Guide

Focus: Pairing the Microsoft Learn MCP connector into Claude Code so the agent grounds every Microsoft answer — Azure, .NET, Entra ID, Microsoft 365, Power Platform — in current, first-party documentation instead of stale training data, and using it to comprehensively research and prep Microsoft products and services.

Overview

The Microsoft Learn MCP Server is a cloud-hosted Model Context Protocol server that lets AI agents pull trusted, up-to-date content directly from Microsoft's official documentation. It is the Microsoft equivalent of this stack's Context7 rule: instead of Claude guessing at an Azure SDK signature or an az CLI flag, the connector fetches the actual current docs and injects them into the conversation.

Core value proposition: ground Microsoft answers in real Microsoft Learn content. Search returns up to 10 ranked chunks (≤500 tokens each); fetch returns a full doc page as markdown; code-sample search returns official, language-filtered snippets.

It is free (rate-limited), remote (no install), uses streamable HTTP, and is unauthenticated — so there is no API key to manage and nothing for this repo's freshness checker to version-track (hence packages: [], like the Context7 and Visual Studio guides). The server has been generally available since 2025-11-07 (preview disclaimers removed); check the release notes for what shipped when.

Not a traditional API. The endpoint is meant to be consumed through an MCP client / agent framework, not called directly as REST. Tool names, request, and response shapes can change; always let the client list tools at init.

Official Documentation

Resource URL
Overview & setup https://learn.microsoft.com/training/support/mcp
Developer reference https://learn.microsoft.com/training/support/mcp-developer-reference
Best practices https://learn.microsoft.com/training/support/mcp-best-practices
Get started (VS Code / Claude Code plugin) https://learn.microsoft.com/training/support/mcp-get-started
Get started (Foundry) https://learn.microsoft.com/training/support/mcp-get-started-foundry
Release notes https://learn.microsoft.com/training/support/mcp-release-notes
FAQ https://learn.microsoft.com/training/support/mcp-faq
GitHub repo https://github.com/MicrosoftDocs/mcp

MCP Server Setup

The server is remote — there is nothing to npm install. You only register the endpoint.

Endpoint:

https://learn.microsoft.com/api/mcp

Since 2026-03-23 Microsoft ships the connector as a first-party Claude Code / Copilot CLI plugin. This is now the recommended path — it bundles the MCP server plus three agent skills that teach Claude to use the tools well (microsoft-docs for concepts/tutorials, microsoft-code-reference for API lookups & code samples, microsoft-skill-creator for generating custom Microsoft skills):

# Run in Claude Code, then restart
/plugin marketplace add microsoftdocs/mcp
/plugin install microsoft-docs@microsoft-docs-marketplace

When installed this way the tools are namespaced under the plugin (e.g. microsoft_docs_search served by the microsoft-learn MCP inside the microsoft-docs plugin).

Add to Claude Code — manual HTTP (alternative)

If you'd rather register just the raw endpoint (no bundled skills):

# Remote HTTP MCP server — no package, no key
claude mcp add --transport http microsoft-learn https://learn.microsoft.com/api/mcp

Verify it registered and the tools surfaced:

claude mcp list

.mcp.json Configuration (project-scoped)

{
  "mcpServers": {
    "microsoft-learn": {
      "type": "http",
      "url": "https://learn.microsoft.com/api/mcp"
    }
  }
}

The Microsoft docs publish the canonical client snippet (works in VS Code, Cursor, Foundry, and most MCP clients) — note the current server key is microsoft.docs.mcp:

{
  "microsoft.docs.mcp": {
    "type": "http",
    "url": "https://learn.microsoft.com/api/mcp"
  }
}

Token-budget control: append ?maxTokenBudget=<n> to the endpoint URL (e.g. https://learn.microsoft.com/api/mcp?maxTokenBudget=2000) to cap the tokens returned in search responses — handy in agentic loops where each call eats context. It truncates search results only; microsoft_docs_fetch always returns the full page.

Available MCP Tools

Tool What it does Reach for it when…
microsoft_docs_search Semantic search → up to 10 chunks (≤500 tokens each), each with title + URL You need a fast, grounded overview or to find the right page
microsoft_docs_fetch Fetches a full Microsoft Learn page → clean markdown You need complete step-by-step procedures, prerequisites, or troubleshooting
microsoft_code_sample_search Returns official code snippets, optional language filter You're about to write Microsoft/Azure code and want the current pattern

How It Works in Practice

Here is the core grounding loop at a glance — search for breadth, fetch for depth, code-sample for the exact pattern, all before writing anything non-trivial.

flowchart TD
    A["Microsoft question<br/>Azure - .NET - Entra - M365"] --> B["microsoft_docs_search<br/>breadth"]
    B --> C["Up to 10 ranked chunks<br/>title plus URL"]
    C --> D["microsoft_docs_fetch<br/>depth"]
    D --> E["Full doc page as markdown"]
    C --> F["microsoft_code_sample_search<br/>practical examples"]
    F --> G["Official language-filtered snippets"]
    E --> H["Grounded answer or code"]
    G --> H

The recommended flow mirrors the Context7 two-step, with a third pass for code:

1. Search for breadth

Tool: microsoft_docs_search
Input: { "query": "deploy ASP.NET web app to Azure App Service az webapp up" }
Output: [up to 10 ranked doc chunks with titles + canonical learn.microsoft.com URLs]

2. Fetch for depth

Tool: microsoft_docs_fetch
Input: { "url": "https://learn.microsoft.com/azure/app-service/quickstart-dotnetcore" }
Output: [the full quickstart as markdown — every step, prerequisite, and CLI flag]

3. Code-sample for the exact pattern

Tool: microsoft_code_sample_search
Input: { "query": "Azure OpenAI chat completions client", "language": "python" }
Output: [official, current snippets you can adapt verbatim]

Best practice (from Microsoft): Search gives breadth. Code Sample Search gives practical examples. Fetch gives depth. Lead with search, then fetch high-value pages before writing anything non-trivial.

Natural-language usage

Once paired, you reference Microsoft docs conversationally and Claude calls the tools for you:

"Using current Microsoft Learn docs, show me how to deploy a .NET 10 app to Azure App Service with az webapp up."

"Fetch the full Azure Functions triggers and bindings page and summarise the HTTP trigger options."

"Find the official C# code sample for chatting with Azure OpenAI on your own data."


Using It Within Limits

The endpoint is free and unauthenticated, but it is a shared public service with rate limits in place to ensure fair usage — Microsoft confirms this in the FAQ and asks for "responsible use" to keep it available for everyone. The repo describes it as "completely free" with "high search capacity tailored for heavy coding sessions" — generous, but not unmetered.

No published numbers. As of this review Microsoft does not publish the specific request/token thresholds. Don't assume a number — assume it's tuned for normal interactive use and budget your calls accordingly. If you hit throttling, back off and raise it in the MicrosoftDocs/mcp repo.

The search → fetch → code flow already is the budgeting pattern — spend calls in that order and most sessions stay well under any limit:

flowchart TD
    A["Microsoft question"] --> B["microsoft_docs_search<br/>one broad query"]
    B --> C{"Right page in<br/>the 10 chunks?"}
    C -->|"no"| D["Refine query<br/>not re-fire"]
    D --> B
    C -->|"yes"| E["microsoft_docs_fetch<br/>1 or 2 best URLs only"]
    E --> F["microsoft_code_sample_search<br/>once · with language"]
    F --> G["Grounded answer<br/>calls minimised"]

Gotcha: the MCP has no topic result-filtering parameter (per the FAQ) — you can't narrow which chunks come back server-side, only scope it inside your question text. So a vague query wastes a whole call returning broad chunks; spend an extra second sharpening the query instead of burning a retry. (There is a ?maxTokenBudget=<n> URL parameter that caps the size of search responses — see Setup — but it truncates, it doesn't filter by relevance.)


Using It to Research & Prep Microsoft Products

This connector is the research engine for evaluating or onboarding any Microsoft service. Below is the verified landscape (sourced live via the connector) of the services most relevant to codeAmani's stack decisions.

AI & agents

Service What it is codeAmani relevance
Azure OpenAI / Azure AI Foundry Hosted OpenAI + other models behind an Azure resource (AZURE_OPENAI_ENDPOINT + deployment name), keyless auth via Entra or API key A third AI-routing option (alongside Anthropic + OpenAI) for enterprise/regulated East-African clients who require data in an Azure tenant
Azure AI Search Vector + keyword search index; the RAG backbone for "chat on your own data" Alternative to Pinecone/pgvector when the rest of the app already lives in Azure
Azure MCP Server A separate, broader MCP server that manages live Azure resources (Storage, App Service, Functions, AI Search, …) Use the Learn server to read docs; use the Azure MCP server to operate resources

Compute & hosting

Service What it is codeAmani relevance
Azure App Service Managed, auto-patching web hosting for .NET / Node / Python; deploy via az webapp up, VS Code, or GitHub Actions The Microsoft analog to Vercel/Render; relevant for .NET workloads the primary stack can't host
Azure Functions Event-driven serverless (HTTP triggers, queues, timers) M-Pesa-style callback/webhook handlers if a client mandates Azure
Azure Container Apps Managed containers with internal-only ingress Hosting a private MCP server or containerised service in a VNet

Identity

Service What it is codeAmani relevance
Microsoft Entra ID (formerly Azure AD) Cloud identity & access management; SSO into Microsoft 365, Azure, and thousands of SaaS apps; supports external identities Enterprise auth path alongside Clerk when a client is Microsoft-365-centric

Research recipe: to prep any of the above, run microsoft_docs_search for the service + "overview", microsoft_docs_fetch the quickstart page, then microsoft_code_sample_search with the target language. You get a grounded, citeable brief without leaving Claude Code.

Two other ways to reach the same knowledge service

The MCP connector isn't the only front door to Learn's Ask Learn knowledge service:


Integration Patterns

Put the rule in CLAUDE.md

Mirror the existing Documentation Policy so Microsoft questions are always grounded:

## Microsoft Documentation Policy

For any Azure, .NET, Entra ID, Microsoft 365, or Power Platform work:
1. Use `microsoft_docs_search` to find the relevant official page(s)
2. Use `microsoft_docs_fetch` on the best match for full, current steps
3. Use `microsoft_code_sample_search` (with a `language`) before writing code
4. Never use remembered Azure API patterns if they differ from fetched docs

Slash command: research a Microsoft product

.claude/commands/ms-research.md:

Research the Microsoft product/service: $ARGUMENTS.

1. `microsoft_docs_search` for "$ARGUMENTS overview" and "$ARGUMENTS quickstart"
2. `microsoft_docs_fetch` the most authoritative result
3. `microsoft_code_sample_search` for a starter snippet in our stack language
4. Summarise: what it is, when to use it, pricing/tier notes, and the canonical
   docs URLs. Flag any codeAmani / M-Pesa / African-market considerations.

Usage: /project:ms-research Azure Container Apps

Pair-with-Context7 routing

Both connectors enforce "no trained-data guessing" — route by source of truth. This quick decision keeps every question pointed at the right grounded source.

flowchart TD
    Q1{"Question about Microsoft<br/>Azure - .NET - Entra - M365?"}
    Q1 -->|"yes"| A["Microsoft Learn MCP"]
    Q1 -->|"no"| Q2{"npm or PyPI library<br/>or framework?"}
    Q2 -->|"yes"| B["Context7"]
    Q2 -->|"no"| C["Use the connector<br/>matching the source of truth"]

Route by source of truth:

Question is about… Use
Microsoft / Azure / .NET / Entra / M365 Microsoft Learn MCP
npm / PyPI library or framework (Next.js, React, Prisma, Supabase JS…) Context7

codeAmani Notes


Troubleshooting

Issue Fix
Tools don't appear Confirm type: "http" (streamable HTTP, not stdio); run claude mcp list to check registration, or install the microsoft-docs plugin and restart
claude mcp add rejects the URL Use the --transport http flag; the endpoint is remote, not a stdio command
A tool call fails with 400/404 Per Microsoft's best practices, assume your cached tool list is stale — the tool set is dynamic; re-list tools (reconnect / restart) and retry
Rate-limited The server is free with rate limits — back off and retry; batch related questions; consider ?maxTokenBudget=<n> to shrink each call
Search results thin Fetch the most relevant URL with microsoft_docs_fetch for full context
Code sample wrong language Pass the language parameter (eligible: csharp, javascript, typescript, python, powershell, azurecli, al, sql, java, kusto, cpp, go, rust, ruby, php)
Answer still feels stale The agent may have skipped the tool — explicitly say "use the Microsoft Learn MCP server" in the prompt

Best practice: pair this connector and add the Microsoft Documentation Policy to CLAUDE.md. The connector makes current docs available; the policy makes Claude actually use them before writing Azure/.NET code.

The file C:\Users\info.claude\tech-stack\microsoft-learn\CLAUDE_CODE_INTEGRATION.md has been written.

Official docs:

MongoDB Integration Guide

Technology: mongodb · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/mongodb

Insight:

MongoDB is advertised on motionstackstudios.com as a database option, but the house default is Neon Postgres + Drizzle (with Supabase as the second). Reach for MongoDB only on client projects that specifically require a document store — flexible/nested schemas, rapid prototyping, or a per-tenant variable shape. Before choosing it, consult the codeAmani-tech-stack MCP and confirm Postgres + jsonb won't do the job; on serverless (Vercel/Netlify) you MUST cache the client across invocations or you will exhaust the Atlas connection pool.

███╗   ███╗ ██████╗ ███╗   ██╗ ██████╗  ██████╗ ██████╗ ██████╗
████╗ ████║██╔═══██╗████╗  ██║██╔════╝ ██╔═══██╗██╔══██╗██╔══██╗
██╔████╔██║██║   ██║██╔██╗ ██║██║  ███╗██║   ██║██║  ██║██████╔╝
██║╚██╔╝██║██║   ██║██║╚██╗██║██║   ██║██║   ██║██║  ██║██╔══██╗
██║ ╚═╝ ██║╚██████╔╝██║ ╚████║╚██████╔╝╚██████╔╝██████╔╝██████╔╝
╚═╝     ╚═╝ ╚═════╝ ╚═╝  ╚═══╝ ╚═════╝  ╚═════╝ ╚═════╝ ╚═════╝

MongoDB Integration Guide

Focus: When and how codeAmani uses MongoDB for client projects that require a document database — Atlas setup and connection strings, the serverless connection-caching pattern (critical on Vercel/Netlify), Mongoose schemas validated with Zod at the edge, the aggregation pipeline, transactions, and MongoDB Vector Search (formerly Atlas Vector Search) as a pgvector/Pinecone alternative for RAG.

Overview

MongoDB is a document database advertised as an option on motionstackstudios.com, and it stores BSON documents in collections rather than rows in tables. That flexibility — nested objects, arrays, per-document variable shape — is its whole point, and also the reason it is not the house default.

The house default for almost everything is Neon Postgres + Drizzle ORM (with Supabase as the second relational option). Postgres with jsonb columns covers a surprising amount of "document-ish" need while keeping the strong typing, joins, transactions, and per-PR branching the rest of the stack already relies on. MongoDB earns a place only when a client build specifically needs a document model — a flexible/evolving schema, deeply nested data that would be painful to normalize, rapid prototyping where the shape is still moving, or a per-tenant document whose fields differ per customer. The decision flow:

flowchart TD
    A["New client build<br/>needs a database"] --> B{"Flexible / nested /<br/>still-moving schema?"}
    B -->|No — relational shape| C["House default<br/>Neon Postgres + Drizzle"]
    B -->|"Maybe — semi-structured"| D{"Does Postgres jsonb<br/>cover it?"}
    D -->|Yes| C
    D -->|"No — true document model"| E["MongoDB Atlas<br/>(managed, per-client)"]
    E --> F["Mongoose schema<br/>+ Zod at the edge"]
    F --> G["Cached client<br/>(serverless pool reuse)"]
    G --> H["Aggregation · transactions<br/>· MongoDB Vector Search"]

Check first. Before adding MongoDB to a build, query the codeAmani-tech-stack MCP (search_guides / get_guide) and confirm the house Neon/Postgres default — including jsonb — won't satisfy the requirement. MongoDB adds a second database paradigm, its own ODM, and serverless connection-pool footguns. Prefer the default unless the client specifically requires a document store.

Official Documentation

Resource URL
MongoDB Docs (root) https://www.mongodb.com/docs/
MongoDB Atlas (managed) https://www.mongodb.com/docs/atlas/
Node.js Driver (v7, current) https://www.mongodb.com/docs/drivers/node/current/
Mongoose ODM (v9, current) https://mongoosejs.com/docs/
MongoDB Vector Search (formerly Atlas Vector Search) https://www.mongodb.com/docs/vector-search/
Aggregation Pipeline https://www.mongodb.com/docs/manual/core/aggregation-pipeline/

Versions (verified 2026-08-23). Node driver mongodb 7.5.0 (v7 line; v6 is 6.x), Mongoose 9.9.3 (v8 still maintained as 8.x). Both v7 and Mongoose 9 require Node.js ≥ 20.19.0 and target ES2023 — pin the runtime accordingly (Vercel/Netlify: Node 20 or 22). Driver v7 (released 2025-11) upgrades bson to 7, uses the official AWS SDK for AWS auth, drops the cursor transform callback, and adds explicit resource management (Symbol.asyncDispose / await using).

MCP note: an official MongoDB MCP server exists and ships with the house plugin stack — it can find, aggregate, inspect collection-schema, manage create-index, and drive Atlas local deployments from Claude Code. Use it for read/inspection and scaffolding, but treat production writes through it with the same care as a CLI against prod.


MongoDB Atlas Setup & Connection String

MongoDB Atlas is the managed, multi-cloud MongoDB service and is the only deployment shape codeAmani uses for clients — never self-host a mongod for a client build. Setup:

  1. Create a project and a cluster (an M0 free tier or Flex cluster is fine for prototypes; dedicated M10+ for production with PHI/regulated data). Note: Flex (GA Feb 2025) replaced the old M2/M5 shared tiers and the legacy Serverless instances — those were auto-migrated and reached end-of-life on 2026-01-22, so provision M0 / Flex / dedicated only.
  2. Create a database user (username + password) under Database Access.
  3. Add the deploying environment's egress to the Network Access IP allowlist. Serverless platforms have dynamic egress IPs — for Vercel/Netlify Lambda you typically allow 0.0.0.0/0 and rely on TLS + SCRAM auth + a strong user password, or front it with a static-egress proxy. Lock down to known CIDRs whenever the platform supports it.
  4. Copy the SRV connection string from Connect → Drivers.

The SRV string carries the cluster host, credentials, and sensible defaults. Always keep retryWrites=true&w=majority:

mongodb+srv://<user>:<urlEncodedPassword>@cluster0.xxxxx.mongodb.net/<dbName>?retryWrites=true&w=majority&appName=acme-app

Gotcha: the password must be URL-encoded in the connection string. A literal @, :, /, or # in the password will break parsing — encode P@ss:w0rd as P%40ss%3Aw0rd. Store the assembled URI in MONGODB_URI (server-side secret) and never inline credentials in code.


Serverless Connection Caching (critical on Vercel/Netlify)

This is the single most important pattern in the guide. On serverless platforms each invocation may spin up a fresh Lambda; if you call new MongoClient(...).connect() per request you open a new pool every time and exhaust the Atlas connection limit under load. The fix is to cache the connection on the Node module/global scope so warm invocations reuse it.

Raw driver (mongodb v7) — cached client singleton

// lib/mongodb.ts
import { MongoClient, type Db } from "mongodb";

const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("Missing MONGODB_URI environment variable");

// In serverless, cache the connect() promise on globalThis so it survives
// module re-evaluation across warm invocations (and HMR in dev).
const options = { maxPoolSize: 10, minPoolSize: 0 };

declare global {
  // eslint-disable-next-line no-var
  var _mongoClientPromise: Promise<MongoClient> | undefined;
}

let clientPromise: Promise<MongoClient>;

if (process.env.NODE_ENV === "development") {
  // Reuse across hot-reloads in dev.
  if (!global._mongoClientPromise) {
    global._mongoClientPromise = new MongoClient(uri, options).connect();
  }
  clientPromise = global._mongoClientPromise;
} else {
  // In production, the module-scoped promise is reused by warm invocations.
  clientPromise = new MongoClient(uri, options).connect();
}

export async function getDb(dbName = "app"): Promise<Db> {
  const client = await clientPromise;
  return client.db(dbName);
}

export default clientPromise;

Why a cached promise, not a cached client? Caching the connect() promise means concurrent cold-start requests await the same in-flight connection instead of each opening a pool. Never call client.close() in a request handler on serverless — let the platform recycle the container. Driver v7 adds explicit resource management (await using client = new MongoClient(...), which disposes on scope exit); that is the opposite of what you want per request on serverless — keep the module-scoped cached client and do not wrap it in await using.

Mongoose — cached connection

Mongoose needs the same treatment. Cache both the connection and its in-flight promise:

// lib/dbConnect.ts
import mongoose from "mongoose";

const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) throw new Error("Missing MONGODB_URI environment variable");

interface MongooseCache {
  conn: typeof mongoose | null;
  promise: Promise<typeof mongoose> | null;
}

const globalForMongoose = global as unknown as { mongoose?: MongooseCache };
const cached: MongooseCache = globalForMongoose.mongoose ?? { conn: null, promise: null };
globalForMongoose.mongoose = cached;

export default async function dbConnect(): Promise<typeof mongoose> {
  if (cached.conn) return cached.conn;
  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI, {
      bufferCommands: false, // fail fast instead of queueing before connect
      maxPoolSize: 10,
    });
  }
  cached.conn = await cached.promise;
  return cached.conn;
}
flowchart LR
    A["Request hits<br/>serverless function"] --> B{"Cached connection<br/>on globalThis?"}
    B -->|Yes — warm| C["Reuse pool<br/>(no new connection)"]
    B -->|No — cold| D["await connect()<br/>store promise"]
    D --> C
    C --> E["Query Atlas"]
    F["Per-request new MongoClient()"] -.->|"connection-pool<br/>exhaustion ✗"| G["Atlas refuses<br/>new connections"]

Note: these are Node.js runtime patterns. On Next.js, mark routes that touch MongoDB with export const runtime = "nodejs" — the driver and Mongoose do not run on the Edge runtime. For Edge/Workers, route the DB call through a Node function or use a Postgres/Neon path instead. Do not reach for the Atlas Data API / custom HTTPS Endpoints — MongoDB deprecated them and they reached end-of-life on 2025-09-30; they are gone, not an Edge escape hatch.


Mongoose Schema + Zod at the Edge

The house pattern mirrors the relational stack: validate input at the boundary with Zod, then persist with a typed Mongoose model. Zod guards the untrusted edge (request bodies, webhooks); the Mongoose schema is the persistence contract and last-line validation. Define an explicit TypeScript interface so types don't drift.

// models/Provider.ts
import { Schema, model, models, type InferSchemaType, type Model } from "mongoose";
import { z } from "zod";

// 1. Zod schema validates the untrusted edge (API body, webhook payload).
export const ProviderInput = z.object({
  name: z.string().min(1),
  licenseType: z.enum(["APD", "AHCA", "DCF"]),
  email: z.string().email(),
  services: z.array(z.string()).default([]),
  metadata: z.record(z.string(), z.unknown()).optional(), // flexible per-client shape
});
export type ProviderInput = z.infer<typeof ProviderInput>;

// 2. Mongoose schema is the persistence contract + last-line validation.
const providerSchema = new Schema(
  {
    name: { type: String, required: true, trim: true },
    licenseType: { type: String, required: true, enum: ["APD", "AHCA", "DCF"] },
    email: { type: String, required: true, lowercase: true, index: true },
    services: { type: [String], default: [] },
    metadata: { type: Schema.Types.Mixed }, // free-form nested document
  },
  { timestamps: true, strict: true },
);

export type Provider = InferSchemaType<typeof providerSchema>;

// 3. `models.Provider ?? model(...)` prevents OverwriteModelError on hot-reload
//    and across warm serverless invocations.
export const ProviderModel: Model<Provider> =
  (models.Provider as Model<Provider>) ?? model<Provider>("Provider", providerSchema);

Usage in a route handler — Zod first, then Mongoose:

// app/api/providers/route.ts
import { NextResponse } from "next/server";
import dbConnect from "@/lib/dbConnect";
import { ProviderInput, ProviderModel } from "@/models/Provider";

export const runtime = "nodejs";

export async function POST(req: Request) {
  const parsed = ProviderInput.safeParse(await req.json());
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }
  await dbConnect();
  const provider = await ProviderModel.create(parsed.data);
  return NextResponse.json({ id: provider._id.toString() }, { status: 201 });
}

Schema validation belongs on both sides. Use Atlas/MongoDB JSON Schema validation at the collection level ($jsonSchema validators) as a server-enforced backstop for writes that bypass the app (scripts, the MCP, direct driver access). The Mongoose schema does not protect the database from other clients — collection validators do.


Aggregation Pipeline

The aggregation pipeline is MongoDB's query engine for analytics, joins ($lookup), grouping, and reshaping. Stages run in order; the output of one feeds the next. A typical "providers and their open intake counts" rollup:

// lib/reports.ts
import { getDb } from "@/lib/mongodb";

export async function providerIntakeSummary(licenseType: "APD" | "AHCA" | "DCF") {
  const db = await getDb();
  const cursor = db.collection("providers").aggregate([
    { $match: { licenseType } },
    {
      $lookup: {
        from: "intakes",
        localField: "_id",
        foreignField: "providerId",
        as: "intakes",
      },
    },
    {
      $project: {
        _id: 0,
        name: 1,
        email: 1,
        openIntakes: {
          $size: {
            $filter: {
              input: "$intakes",
              as: "i",
              cond: { $eq: ["$$i.status", "open"] },
            },
          },
        },
      },
    },
    { $sort: { openIntakes: -1 } },
    { $limit: 50 },
  ]);

  return cursor.toArray();
}

Index the $match and $sort fields. Aggregations are only fast when the early $match/$sort stages hit an index; otherwise Atlas does a full collection scan. Use the MCP explain tool (or db.collection.explain()) to confirm an IXSCAN, not a COLLSCAN.


Transactions

MongoDB supports multi-document ACID transactions on replica sets (every Atlas cluster is a replica set). Use them when you must write to multiple documents/collections atomically — e.g. debiting one balance and crediting another. The driver's withTransaction helper handles commit/abort and transient-error retries:

// lib/transfer.ts
import { getDb } from "@/lib/mongodb";
import clientPromise from "@/lib/mongodb";

export async function recordIntake(providerId: string, intake: Record<string, unknown>) {
  const client = await clientPromise;
  const db = await getDb();
  const session = client.startSession();
  try {
    await session.withTransaction(async () => {
      await db.collection("intakes").insertOne({ providerId, ...intake, status: "open" }, { session });
      await db
        .collection("providers")
        .updateOne({ _id: providerId }, { $inc: { openCount: 1 } }, { session });
    });
  } finally {
    await session.endSession();
  }
}

Gotcha: transactions have a default 60-second limit and are not free — they hold locks and add latency. Reach for them only when atomicity across documents is genuinely required. Often, embedding related data in a single document (MongoDB's strength) removes the need for a transaction entirely.


MongoDB Vector Search (RAG — pgvector/Pinecone alternative)

Renamed: what was Atlas Vector Search is now branded MongoDB Vector Search (docs moved to mongodb.com/docs/vector-search/). The $vectorSearch aggregation stage is unchanged; the older knnBeta operator is superseded — always use $vectorSearch.

MongoDB Vector Search stores embeddings alongside your documents and runs approximate-nearest-neighbor search via the $vectorSearch aggregation stage. For RAG, it is a real alternative to the house options: pgvector on Neon (when you're already on Postgres) and Pinecone (dedicated vector DB). Choose it when the client is already on MongoDB and you want embeddings to live next to the source documents — no second datastore to sync. (Indexes support embeddings up to 8192 dimensions.)

First, create a vector search index on the embedding field (via Atlas UI, the MCP, or the driver). A 1536-dim index for OpenAI text-embedding-3-small, cosine similarity:

{
  "fields": [
    {
      "type": "vector",
      "path": "embedding",
      "numDimensions": 1536,
      "similarity": "cosine"
    },
    { "type": "filter", "path": "tenantId" }
  ]
}

Then query with $vectorSearch as the first pipeline stage:

// lib/vectorSearch.ts
import { getDb } from "@/lib/mongodb";

export async function searchDocs(queryEmbedding: number[], tenantId: string, k = 5) {
  const db = await getDb();
  return db
    .collection("knowledge")
    .aggregate([
      {
        $vectorSearch: {
          index: "knowledge_vector_index",
          path: "embedding",
          queryVector: queryEmbedding,
          numCandidates: 150, // ~10-20x limit for good recall
          limit: k,
          filter: { tenantId }, // pre-filter so tenants never see each other's docs
        },
      },
      {
        $project: {
          _id: 0,
          text: 1,
          source: 1,
          score: { $meta: "vectorSearchScore" },
        },
      },
    ])
    .toArray();
}
RAG store When to choose
Neon + pgvector (house default for Postgres builds) Already on Postgres; want vectors in the same DB as relational data; SQL filtering
Pinecone (house dedicated option) Large-scale, vector-first workloads; managed ANN at scale; multi-tenant namespaces
MongoDB Vector Search (formerly Atlas Vector Search) Already on MongoDB; want embeddings beside the source documents; one datastore

Multi-tenant safety: always pass a filter (e.g. tenantId) in $vectorSearch and index it as a filter field — this is the same isolation discipline as a WHERE tenant_id = ... clause. Never return another tenant's vectors.


Environment Variables

# Atlas SRV connection string — server-side secret. NEVER ship to the browser bundle.
# Password MUST be URL-encoded. Keep retryWrites=true&w=majority.
MONGODB_URI=mongodb+srv://app_user:URL%2DENCODED%2DPASS@cluster0.xxxxx.mongodb.net/app?retryWrites=true&w=majority&appName=acme-app

# Optional: explicit database name if not in the URI path
MONGODB_DB=app

Add these to ENV_MASTER.md and each project's .env.example. MONGODB_URI is a server-only secret — it contains credentials; never expose it via NEXT_PUBLIC_* or any client bundle. In production, source it from the secrets manager (Infisical / Vercel env vars), not a committed file.


Common Use Cases

Use Case Approach
Default relational database Neon Postgres + Drizzle (house default) — use MongoDB only for true document needs
Semi-structured / occasional nesting Postgres jsonb on Neon before reaching for MongoDB
Flexible / evolving / per-tenant schema MongoDB Atlas + Mongoose Schema.Types.Mixed
Serverless connection (Vercel/Netlify) Cached client/connection promise on globalThis (never connect per request)
Input validation Zod at the edge + Mongoose schema + collection $jsonSchema validator
Analytics / joins / rollups Aggregation pipeline ($match → $lookup → $group → $project)
Atomic multi-document writes session.withTransaction(...) (only when embedding can't avoid it)
RAG / semantic search on Mongo data MongoDB Vector Search $vectorSearch (else Neon+pgvector or Pinecone)
Inspect schema / indexes / run queries MongoDB MCP (collection-schema, collection-indexes, find, aggregate, explain)

Troubleshooting

Issue Fix
Connection-pool exhaustion on serverless Cache the connect() promise on globalThis; never new MongoClient() per request, never close() in a handler
MongoParseError / auth fails on connect URL-encode the password in MONGODB_URI (@→%40, :→%3A); verify the DB user under Database Access
MongoServerSelectionError / timeout Add the platform's egress to the Atlas Network Access allowlist; on dynamic-IP serverless allow 0.0.0.0/0 + rely on TLS/SCRAM
OverwriteModelError: Cannot overwrite model Guard model creation with models.X ?? model("X", schema) so HMR/warm invocations reuse it
MongooseError: Operation buffering timed out Set bufferCommands: false and await dbConnect() before any query; the connection wasn't established
Runs on Edge / Workers and fails Driver & Mongoose are Node-only — set export const runtime = "nodejs" (or call the DB from a Node function). The Atlas Data API is EOL (2025-09-30) — don't use it as an Edge fallback; use a Neon/Postgres path instead
$vectorSearch returns nothing Create the vector search index first; match numDimensions to the embedding model; ANN indexing takes a moment to build
Aggregation is slow (COLLSCAN) Index the early $match/$sort fields; confirm IXSCAN via explain
Transaction WriteConflict / aborts Use withTransaction (retries transient errors); reduce contention or embed data to avoid the transaction
Choosing Mongo vs the house default Consult the codeAmani-tech-stack MCP first — prefer Neon Postgres + jsonb unless a true document model is required

Official docs:

Neon Integration Guide

Technology: neon · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/neon

Insight:

Neon is serverless Postgres whose killer feature is branching — spin up an instant copy-on-write branch per preview deploy or migration test, then discard it. It scales to zero (cheap for spiky SME traffic) and supports pgvector for in-database vector search. Now Databricks-owned (it powers Databricks Lakebase), but the free-standing Neon platform, CLI, and MCP server remain the codeAmani path.

███╗   ██╗███████╗ ██████╗ ███╗   ██╗
████╗  ██║██╔════╝██╔═══██╗████╗  ██║
██╔██╗ ██║█████╗  ██║   ██║██╔██╗ ██║
██║╚██╗██║██╔══╝  ██║   ██║██║╚██╗██║
██║ ╚████║███████╗╚██████╔╝██║ ╚████║
╚═╝  ╚═══╝╚══════╝ ╚═════╝ ╚═╝  ╚═══╝

Neon Integration Guide

Focus: Managing serverless Postgres, running migrations, and using branching workflows from Claude Code via the official Neon MCP server.

Overview

Neon is a serverless Postgres platform (acquired by Databricks in 2025; it now also powers Databricks Lakebase) with scale-to-zero, database branching, and instant provisioning. Its official MCP server lets Claude Code create projects, run SQL, branch databases per feature, apply migrations safely with a two-phase commit pattern, inspect a database's health, and query slow query logs — all from natural language inside your session.

Here is the branching workflow at a glance — copy-on-write branches let you experiment freely, then discard or reset with zero risk to main:

flowchart LR
    A["main branch<br/>production data"] -->|"create_branch"| B["feature branch<br/>copy-on-write"]
    B --> C["run SQL · test migration"]
    C --> Q1{"Looks good?"}
    Q1 -->|"yes"| D["merge changes to main"]
    Q1 -->|"no"| E["reset_from_parent<br/>or delete_branch"]
    E --> B

Official Documentation

Resource URL
Neon Docs https://neon.com/docs
Neon MCP Server https://neon.com/docs/ai/neon-mcp-server
Branching Guide https://neon.com/docs/introduction/branching
Neon CLI https://neon.com/docs/reference/neon-cli
Connection Strings https://neon.com/docs/connect/connect-from-any-app

MCP Server Setup

Neon's preferred approach is the hosted remote server — no API keys to manage. The hosted server speaks Streamable HTTP at https://mcp.neon.tech/mcp (the older /sse endpoint is deprecated and returns 410 Gone on or after 2026-10-01).

# Add via Claude Code CLI (HTTP + OAuth)
claude mcp add --transport http neon https://mcp.neon.tech/mcp

After adding, run /mcp inside Claude Code to authenticate via OAuth. (npx neon@latest init also scaffolds the MCP config for most editors if you prefer a guided setup.)

.mcp.json Configuration (Remote)

{
  "mcpServers": {
    "neon": {
      "type": "http",
      "url": "https://mcp.neon.tech/mcp"
    }
  }
}

The MCP server is for development/testing only — never point it at a production database. Restrict scope by appending URL parameters to the MCP URL:

Parameter Effect
?readonly=true SELECTs + schema inspection only; branch/migration/auth writes disabled (OAuth can also grant read-only scope at authorize time)
?projectId=<id> Scope every operation to one project; cross-project search off
?category=<name> Enable only named tool categories (repeatable), e.g. ?category=querying&category=schema

Verify which tools a config exposes without authenticating: curl "https://mcp.neon.tech/api/list-tools?readonly=true&category=querying".

Local stdio fallback (deprecated npm package → use mcp-remote)

The old local package @neondatabase/mcp-server-neon is deprecated (npm marks it so). If your client only supports stdio servers, bridge to the hosted Streamable-HTTP server with mcp-remote instead:

{
  "mcpServers": {
    "neon": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote@latest", "https://mcp.neon.tech/mcp",
        "--header", "Authorization:${NEON_AUTH_HEADER}"
      ],
      "env": { "NEON_AUTH_HEADER": "Bearer ${NEON_API_KEY}" }
    }
  }
}

Drop the --header/env lines to use interactive OAuth instead of an API key.

Available MCP Tools

Tools are grouped into categories (projects, branches, schema, querying, neon_auth, data_api, observability, docs) — filter with ?category=. The commonly used ones:

Tool Description
list_projects / list_organizations List Neon projects / orgs
create_project / delete_project Create or delete a Neon project
describe_project Project details incl. branch list + endpoint defaults
run_sql Execute a SQL query on a branch
run_sql_transaction Run multiple SQL statements as a transaction
get_database_tables List all tables in a database
describe_table_schema Get column definitions and constraints
create_branch Create a database branch (copy-on-write)
describe_branch Deep view of a branch (schema objects, etc.)
delete_branch Delete a branch
reset_from_parent Reset a branch to match parent state
compare_database_schema Diff a branch's schema against its parent
list_branch_computes Per-compute suspend timeout / CU / state (cost diagnosis)
get_connection_string Get psql/prisma/drizzle connection string (pooled optional)
prepare_database_migration Stage a migration on a test branch
complete_database_migration Apply staged migration to main branch
inspect_database Run the 14 read-only health checks from neon inspect db
list_slow_queries Get slow query log for analysis
prepare_query_tuning / complete_query_tuning Test + apply index/query fixes on a branch
explain_sql_statement Get EXPLAIN (ANALYZE) output
list_docs_resources / get_doc_resource Look up Neon docs from the assistant (no OAuth)

There is no standalone list_branches tool — branches come back from describe_project.


CLI Integration

Installation

npm i -g neon      # CLI is invoked as `neon`; `neonctl` is a published alias (same binary)

The examples below use neonctl; every command works identically as neon. In agentic/CI contexts prefer npx neon ... (or npx neonctl ...) rather than assuming a global install.

Authentication

neonctl auth       # OAuth browser login
# Or use API key (CI/scripts):
export NEON_API_KEY=...

Key Commands

# Projects
neonctl projects list
neonctl projects create --name my-project
neonctl projects delete PROJECT_ID

# Branches
neonctl branches list --project-id PROJECT_ID
neonctl branches create --project-id PROJECT_ID --name feature/auth
neonctl branches delete BRANCH_ID --project-id PROJECT_ID
neonctl branches reset BRANCH_ID --project-id PROJECT_ID --parent

# Connection string
neonctl connection-string --project-id PROJECT_ID --branch main
neonctl connection-string --project-id PROJECT_ID --branch feature/auth

# SQL execution
neonctl sql --project-id PROJECT_ID --query "SELECT version();"
neonctl sql --project-id PROJECT_ID --file schema.sql

Connecting to Postgres

Get Connection String

# Via MCP tool: get_connection_string
# Or via CLI:
neonctl connection-string --project-id PROJECT_ID --branch main

Example connection string:

postgresql://user:password@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require

Prisma Integration

npm install prisma @prisma/client

prisma/schema.prisma:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // Use a separate direct URL for migrations (no connection pooling)
  directUrl = env("DIRECT_URL")
}

generator client {
  provider = "prisma-client-js"
}
npx prisma migrate dev --name add-users
npx prisma generate

Drizzle ORM Integration

npm install drizzle-orm pg drizzle-kit
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);

// Query
const users = await db.select().from(usersTable).limit(10);

pgvector on Neon

Neon ships the pgvector extension, so you can store embeddings and run nearest-neighbour search inside the same Postgres branch as the rest of your data — no separate vector database to provision. This pairs naturally with the AI routing policy: generate embeddings with Anthropic/OpenAI/HuggingFace, then query them here.

Here is the end-to-end flow from raw text to a ranked similarity result:

flowchart LR
    A["app text"] -->|"embeddings API"| B["vector float array"]
    B --> C["INSERT into items.embedding"]
    C --> D["HNSW index"]
    D -->|"query vector + ORDER BY + LIMIT"| E["top-k nearest rows"]

Enable the extension, create a table + HNSW index

Run this on a branch first (use create_branch) so you can validate before touching main:

-- 1. Enable pgvector
CREATE EXTENSION IF NOT EXISTS vector;

-- 2. Table with a vector column.
--    Dimension must match your embedding model:
--    OpenAI text-embedding-3-small = 1536, Cohere embed-v3 = 1024, etc.
CREATE TABLE items (
  id        BIGSERIAL PRIMARY KEY,
  content   TEXT,
  embedding VECTOR(1536)
);

-- 3. HNSW index. Pick the operator class that matches your distance metric:
--    vector_cosine_ops (cosine), vector_l2_ops (L2), vector_ip_ops (inner product).
--    Cosine is the usual choice for normalized OpenAI/Cohere embeddings.
CREATE INDEX ON items
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

Nearest-neighbour query

Use the distance operator that matches the index operator class: <=> (cosine), <-> (L2), <#> (negative inner product). The index is only used when the query has both ORDER BY on the distance operator and a LIMIT:

SELECT id, content
FROM items
ORDER BY embedding <=> '[0.012, -0.034, 0.567, ...]'   -- your query embedding
LIMIT 5;

From TypeScript (Drizzle + a query embedding)

import { drizzle } from "drizzle-orm/node-postgres";
import { sql } from "drizzle-orm";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);

// queryEmbedding is a number[] from your embeddings provider (e.g. 1536-dim).
async function searchSimilar(queryEmbedding: number[], k = 5) {
  // pgvector accepts the array as a bracketed string literal: '[0.1,0.2,...]'
  const literal = `[${queryEmbedding.join(",")}]`;
  const rows = await db.execute(sql`
    SELECT id, content
    FROM items
    ORDER BY embedding <=> ${literal}
    LIMIT ${k}
  `);
  return rows.rows;
}

Gotcha — HNSW dimension ceiling. An HNSW index on the standard vector type supports at most 2,000 dimensions. Models that exceed this (e.g. OpenAI text-embedding-3-large at 3072 dims) cannot be indexed as vector — use the halfvec type instead (16-bit floats, up to 4,000 dimensions indexed): CREATE INDEX ON items USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);. Without an HNSW index, large-dimension columns still work for storage and exact search, just without ANN acceleration.

See the pgvector on Neon guide for index tuning (ef_search), IVFFlat as an alternative index, and the full operator/type matrix.


Environment Variables

# Connection strings (from Neon dashboard or neonctl connection-string)
DATABASE_URL=postgresql://user:pass@ep-....neon.tech/neondb?sslmode=require
DIRECT_URL=postgresql://user:pass@ep-....neon.tech/neondb?sslmode=require

# API key (for CLI and npm MCP package)
NEON_API_KEY=...

# Project and branch (optional, for scripting)
NEON_PROJECT_ID=...
NEON_BRANCH_NAME=main

Automation Workflows

Safe Two-Phase Migration Pattern

Neon's MCP server implements a safe migration pattern that tests first on a branch:

You can apply schema changes with confidence — the migration is rehearsed on a throwaway branch before it ever touches main:

sequenceDiagram
    participant CC as Claude Code
    participant Neon as Neon MCP
    CC->>Neon: prepare_database_migration
    Neon->>Neon: create test branch · run migration
    Neon-->>CC: result for review
    CC->>CC: check for errors and data issues
    CC->>Neon: complete_database_migration
    Neon->>Neon: apply migration to main branch
    Neon-->>CC: migration confirmed
  1. Claude Code calls prepare_database_migration → creates a test branch, runs migration there
  2. Review the output — Claude Code checks for errors and data issues
  3. Call complete_database_migration → applies the migration to the main branch

This prevents irreversible migrations from running against production directly.

In practice, ask Claude Code:

"Apply this migration to the Neon database: ALTER TABLE users ADD COLUMN avatar_url TEXT"

Claude Code will automatically use the two-phase pattern via the MCP tools.

Claude Code Slash Command: Branch per Feature

.claude/commands/neon-branch.md:

Create a Neon database branch for feature $ARGUMENTS.

1. Use the Neon MCP tool `create_branch` to create a branch named "feature/$ARGUMENTS"
2. Use `get_connection_string` to get the connection string for this branch
3. Update the `.env.local` file's DATABASE_URL to point to the new branch
4. Report the branch ID, connection string, and confirm `.env.local` was updated

Usage: /project:neon-branch user-auth

GitHub Actions: Branch per PR

# .github/workflows/neon-preview.yml
name: Neon Preview Branch
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  create-branch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Create Neon branch
        uses: neondatabase/create-branch-action@v6
        id: create-branch
        with:
          project_id: ${{ secrets.NEON_PROJECT_ID }}
          api_key: ${{ secrets.NEON_API_KEY }}
          branch_name: preview/pr-${{ github.event.pull_request.number }}
      - name: Comment branch URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `DB branch id: \`${{ steps.create-branch.outputs.branch_id }}\`\nConnection: \`${{ steps.create-branch.outputs.db_url_pooled }}\``
            })

Common Use Cases

Use Case Approach
Feature branch isolation MCP create_branch per PR
Safe schema migrations Two-phase: prepare → complete
Query analysis MCP explain_sql_statement + list_slow_queries
Database health check MCP inspect_database (14 read-only checks)
Guided query tuning MCP prepare_query_tuning → complete_query_tuning
Instant dev environment create_project in under 2 seconds
Prisma migrations prisma migrate dev against branch URL
Reset dev DB MCP reset_from_parent

Troubleshooting

Issue Fix
Connection timeout Add ?sslmode=require&connect_timeout=10 to URL
Branch creation fails Check project branch limit (free tier: 10 branches)
Slow queries Use MCP list_slow_queries + explain_sql_statement
Migration conflicts Reset branch with MCP reset_from_parent
Prisma P1001 Endpoint may be sleeping — first query wakes it (scale-to-zero)
API key invalid Regenerate at console.neon.tech → Account Settings

Official docs:

Netlify Integration Guide

Technology: netlify · Category: hosting · Last reviewed: 2026-09-24

Source: https://tech-stack.codeamanilabs.org/guide/netlify

Insight:

Netlify is the alternative host to Vercel — similar git-driven deploys and edge functions. Pick it when a project already lives there or needs Netlify-specific features (Forms, Identity, or Netlify Database, whose per-deploy-preview Postgres branching has no Vercel equivalent); otherwise default to Vercel for stack consistency.

███╗   ██╗███████╗████████╗██╗     ██╗███████╗██╗   ██╗
████╗  ██║██╔════╝╚══██╔══╝██║     ██║██╔════╝╚██╗ ██╔╝
██╔██╗ ██║█████╗     ██║   ██║     ██║█████╗   ╚████╔╝
██║╚██╗██║██╔══╝     ██║   ██║     ██║██╔══╝    ╚██╔╝
██║ ╚████║███████╗   ██║   ███████╗██║██║        ██║
╚═╝  ╚═══╝╚══════╝   ╚═╝   ╚══════╝╚═╝╚═╝        ╚═╝

Netlify Integration Guide

Focus: Deploying sites, managing edge functions, and automating Netlify projects from Claude Code using the official Netlify MCP server and netlify-cli.

Overview

Netlify is a platform for hosting web apps with built-in CI/CD, edge functions, forms, and identity. Its official MCP server (launched Feb 2025) lets Claude Code create projects, trigger deploys, manage environment variables, install extensions, and query build logs — all via natural language inside your coding session.

Here is the big picture of how a change reaches your users — once you see the flow, the commands below click into place:

flowchart LR
  A["git push"] --> B["Netlify CI/CD<br/>build"]
  B --> C{"PR or<br/>main?"}
  C -->|"PR"| D["Preview deploy<br/>unique URL"]
  C -->|"main"| E["Production deploy"]
  E --> F["Edge network<br/>CDN"]
  F --> G["Users"]

Platform Primitives

Netlify's capabilities are exposed as framework-agnostic platform primitives rather than framework features. Whatever you build with — Next.js, Astro, Nuxt, Remix, TanStack Start, SvelteKit — the framework's server code is compiled into Netlify Functions and Edge Functions at build time, and every primitive below becomes available without framework-specific plumbing.

That is the whole design argument: you are not waiting for your framework to add support for image optimisation or cache control, because those live a layer below it.

flowchart TD
  A["Your framework<br/>Next · Astro · Nuxt · Remix · TanStack"] --> B["Build"]
  B --> C["Functions<br/>regional Node"]
  B --> D["Edge Functions<br/>Deno at CDN"]
  C --> E[("Database<br/>Postgres")]
  C --> F[("Blobs<br/>key-value")]
  C --> G["AI Gateway"]
  D --> H["Edge Network<br/>cache + Netlify-Vary"]
  C --> H
  H --> I["Users"]
Primitive What it is Section
Functions Regional Node handlers, web-standard Request → Response, 60s sync / 15 min background Serverless Functions
Edge Functions Deno at the CDN node — auth gates, redirects, geo, A/B Edge Function Example
Netlify Database Managed Postgres with per-preview branching and automatic migrations Netlify Database
Blobs Zero-config key-value / object store, callable from Functions and the CLI —
Image CDN On-demand resize + format negotiation via /.netlify/images —
Caching Netlify-CDN-Cache-Control, cache tags, SWR, durable cache Edge Network
AI Gateway Keyless access to OpenAI / Anthropic / Gemini / OpenRouter models AI Gateway
Agent Runners Coding agents run on Netlify, one DB branch + preview per run Agent Runners
Forms HTML form capture with no backend code —
Identity Built-in auth (GoTrue) — codeAmani uses Clerk instead Security

Everything above runs locally. netlify dev emulates the full set — Functions, Edge Functions, Blobs, the database, AI Gateway, redirects, and the Image CDN — and Vite projects get the same through @netlify/vite-plugin without invoking the CLI. For tests, @netlify/dev exposes that emulator as a library. This is the practical reason the local/production gap on Netlify is small: it is the same engine, not a mock.


Official Documentation

Resource URL
Netlify Docs https://docs.netlify.com
Netlify MCP Server https://docs.netlify.com/build/build-with-ai/netlify-mcp-server/
Netlify CLI Reference https://docs.netlify.com/cli/get-started/
Edge Functions https://docs.netlify.com/edge-functions/overview/
Netlify Functions https://docs.netlify.com/functions/overview/
Netlify Database https://docs.netlify.com/build/data-and-storage/netlify-database/
Database: getting started https://docs.netlify.com/build/data-and-storage/netlify-database/getting-started/
Database: API reference https://docs.netlify.com/build/data-and-storage/netlify-database/api/
Database: migrations https://docs.netlify.com/build/data-and-storage/netlify-database/migrations/
Database: CLI reference https://docs.netlify.com/build/data-and-storage/netlify-database/cli/
Database: billing & limits https://docs.netlify.com/build/data-and-storage/netlify-database/billing-and-usage/
AI Gateway https://docs.netlify.com/build/ai-gateway/overview/
AI Gateway: quickstart https://docs.netlify.com/build/ai-gateway/quickstart-for-ai-gateway/
Agent Runners https://docs.netlify.com/build/build-with-ai/agent-runners/overview/
Observability https://docs.netlify.com/manage/monitoring/observability/overview/
Log drains https://docs.netlify.com/manage/monitoring/log-drains/
Security overview https://docs.netlify.com/manage/security/overview/
Secrets Controller https://docs.netlify.com/build/environment-variables/secrets-controller/
Caching (Edge Network) https://docs.netlify.com/build/caching/caching-overview/

MCP Server Setup

Official Netlify MCP Server

The official Netlify MCP server ships as the @netlify/mcp npm package (repo: netlify/netlify-mcp). The older netlify-mcp package name no longer exists — use @netlify/mcp or the hosted remote endpoint.

# Local (stdio) — runs the package via npx
claude mcp add netlify -- npx -y @netlify/mcp

# Remote (hosted) — Netlify's managed endpoint, handles auth via OAuth in the client
npx -y add-mcp https://netlify-mcp.netlify.app/mcp

.mcp.json Configuration

{
  "mcpServers": {
    "netlify": {
      "command": "npx",
      "args": ["-y", "@netlify/mcp"],
      "env": {
        "NETLIFY_AUTH_TOKEN": "${NETLIFY_AUTH_TOKEN}"
      }
    }
  }
}

Requirements: Node.js 22+, a Netlify account with a personal access token (NETLIFY_AUTH_TOKEN). The remote endpoint (https://netlify-mcp.netlify.app/mcp) authenticates over OAuth instead of a token and needs no local Node runtime.

Available MCP Tools

The current server is capability/service based (not one tool per action). Tools group into reader/updater services plus a coding-context helper:

Tool Description
get-netlify-coding-context Fetch Netlify's current best-practice context for a capability (serverless, edge-functions, blobs, image-cdn, forms, db) — call before writing Netlify code
netlify-project-services-reader / …-updater Read / create + configure projects (sites), env vars, and project settings
netlify-deploy-services-reader / …-updater Query deploy status + logs; trigger and manage deploys
netlify-extension-services-reader / …-updater Discover and install/configure Netlify extensions
netlify-team-services-reader Team, membership, and billing info
netlify-user-services-reader Authenticated user info

The server also exposes documentation skills for each primitive: netlify-functions, netlify-edge-functions, netlify-blobs, netlify-db, netlify-image-cdn, netlify-forms, netlify-config, netlify-cli-and-deploy, netlify-caching, and netlify-ai-gateway.


CLI Integration

Installation

npm install -g netlify-cli

Current major is netlify-cli v27 (requires Node.js 22+). Verify with netlify --version; upgrade with npm install -g netlify-cli@latest.

Authentication

# Interactive login (browser OAuth)
netlify login

# Token-based (for CI)
export NETLIFY_AUTH_TOKEN=...

Key Commands

# Initialize / link a site
netlify init
netlify link

# Deploy (draft)
netlify deploy

# Deploy to production
netlify deploy --prod

# Open site dashboard
netlify open

# Run dev server (with Functions/Edge Functions)
netlify dev

# Invoke a serverless function locally
netlify functions:invoke my-function --payload '{"key":"value"}'

# Manage environment variables
netlify env:set MY_VAR "my-value"
netlify env:list
netlify env:unset MY_VAR

# View build and deploy logs
netlify status
netlify logs:deploy

# Pull environment to local file
netlify env:import .env

Environment Variables

# Required for MCP and CLI
NETLIFY_AUTH_TOKEN=...          # From app.netlify.com/user/applications

# Optional project-specific
NETLIFY_SITE_ID=...             # From site settings or `netlify link`

# Variables set for your deployed site
DATABASE_URL=postgresql://...     # only if you bring your own database
NEXT_PUBLIC_API_URL=https://api.example.com

NETLIFY_DB_URL is injected for you. If the project uses Netlify Database, Netlify sets this connection string automatically in builds, agent runners, functions, and edge functions, resolved to the correct database branch for the current deploy. Never set it by hand, never commit it, and never pin it in a secrets vault — a hard-coded value will point at the wrong branch (or a rotated credential). Read it via Netlify.env.get("NETLIFY_DB_URL"), or better, call getConnectionString() from @netlify/database.

Inside function code, read variables via the global Netlify.env — not process.env. Netlify injects a web-standard Netlify object into both serverless and edge functions:

const key = Netlify.env.get("DARAJA_CONSUMER_SECRET"); // server-side only

Store all secrets (Stripe signing secret, Daraja consumer key/secret) as env vars — never in code or in netlify.toml, which is committed.


Automation Workflows

Claude Code Hook: Lint Before Deploy

.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'netlify deploy --prod'; then npm run lint && npm run test; fi"
          }
        ]
      }
    ]
  }
}

Slash Command: Deploy to Netlify

.claude/commands/netlify-deploy.md:

Deploy the current project to Netlify production.

Run the following steps:
1. Use Bash to run `npm run build` and confirm it succeeds
2. Use Bash to run `netlify deploy --prod --dir=dist` (adjust dir as needed)
3. Report the deploy URL and any warnings
4. If deploy fails, use Bash to run `netlify logs:deploy` and report errors

Usage: /project:netlify-deploy

GitHub Actions: Preview + Production Deploy

# .github/workflows/netlify.yml
name: Netlify Deploy
on:
  pull_request:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npm run build

      - name: Deploy Preview
        if: github.event_name == 'pull_request'
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
        run: |
          npm install -g netlify-cli
          deploy_url=$(netlify deploy --dir=dist --json | jq -r '.deploy_url')
          echo "Preview: $deploy_url"

      - name: Deploy Production
        if: github.ref == 'refs/heads/main'
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
        run: netlify deploy --prod --dir=dist

Edge Function Example

Edge functions are great for running logic close to the user before a request reaches your site — here is how the auth example below fits into a request:

sequenceDiagram
  participant U as "User"
  participant E as "Edge Function"
  participant S as "Site"
  U->>E: "Request /api/*"
  E->>E: "Check Authorization header"
  alt "Missing or invalid token"
    E-->>U: "401 Unauthorized"
  else "Valid Bearer token"
    E->>S: "Forward via context.next"
    S-->>U: "Response"
  end

netlify/edge-functions/auth.ts:

import type { Config, Context } from "@netlify/edge-functions";

export default async function handler(req: Request, context: Context) {
  const token = req.headers.get("Authorization");
  if (!token || !token.startsWith("Bearer ")) {
    return new Response("Unauthorized", { status: 401 });
  }
  return context.next();
}

export const config: Config = {
  path: "/api/*",
};

Serverless Functions

Edge functions run light logic at the CDN edge; serverless functions are full Node.js handlers for heavier work — talking to a database, calling the Daraja STK Push API, signing tokens, or handling M-Pesa callbacks. They live in netlify/functions/ and use the same web-standard Request → Response signature as edge functions, but run in a regional Node runtime with the full npm ecosystem available.

Function Example

netlify/functions/stk-push.mts:

import type { Config, Context } from "@netlify/functions";

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") {
    return new Response("Method Not Allowed", { status: 405 });
  }

  const { phone, amount } = await req.json();

  // Heavy lifting belongs here: DB writes, Daraja token + STK Push, etc.
  // const token = await getDarajaToken();
  // const result = await initiateStkPush({ phone, amount, token });

  return new Response(JSON.stringify({ ok: true, phone, amount }), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
};

export const config: Config = {
  path: "/api/stk-push",
};

Install the types once: npm install @netlify/functions. The .mts extension opts into ES modules; netlify/functions/stk-push.mts, netlify/functions/stk-push/stk-push.mts, and netlify/functions/stk-push/index.mts all define a function named stk-push.

How to Invoke

[functions] Config

The default directory is netlify/functions. esbuild is now the default bundler, so this block is optional — set it only to change the directory or opt out:

[functions]
  directory = "netlify/functions"
  node_bundler = "esbuild"

Timeouts & long-running work

Function limits are fixed and uniform across all plans (they are not configurable — the old 10s-free / 26s-Pro tiering is gone):

Function type Execution limit How to opt in
Synchronous (default) 60 seconds default
Background 15 minutes -background suffix on the file/dir name; returns 202 immediately, response body ignored
Scheduled (cron) 30 seconds export const config = { schedule: "@hourly" } — runs on published deploys only, UTC cron

Background and scheduled functions are the escape hatch for work that exceeds 60s (e.g. Daraja STK Push polling, batch AI inference): persist the result to Netlify Blobs or Postgres and read it back from a synchronous function.

Gotcha: Edge vs Serverless

Reach for the right runtime — they are not interchangeable:

flowchart TD
  A["Incoming request"] --> B{"Needs Node APIs<br/>or npm packages?"}
  B -->|"Yes"| C["Serverless function<br/>netlify/functions"]
  B -->|"No · just rewrite<br/>headers or geo"| D{"Must run<br/>at the edge?"}
  D -->|"Yes · low latency"| E["Edge function<br/>netlify/edge-functions"]
  D -->|"No"| C
  C --> F["Regional Node runtime"]
  E --> G["Deno runtime<br/>at CDN edge"]

Edge functions run on Deno at the CDN edge (no full Node API, no node_modules bundling), so M-Pesa/Daraja calls, raw Postgres drivers like pg, and Node-only SDKs belong in serverless functions. Use edge functions only for fast request manipulation (auth gates, redirects, geo, A/B) close to the user.

Exception — @netlify/database. getDatabase() picks a connector suited to its runtime, so it does work in edge functions where a raw pg client would not. A lightweight lookup at the edge (feature flag, tenant routing, session check) is therefore fair game; keep heavier transactional work, which needs db.pool, in serverless functions. See Netlify Database below.


Netlify Database

Netlify Database is a fully managed Postgres database built into the platform. Netlify provisions it, applies migrations, and branches it for you. It is built on Neon (serverless Postgres) under the hood, but the setup and management surface is entirely Netlify's — you never interact with Neon directly, and there is no separate Neon account to link.

The headline feature is database branching. Production deploys are the only deploys allowed to touch the production database; every deploy preview gets its own isolated branch, seeded with a copy of production data taken when the preview is first created. Schema changes and data mutations made in a preview never reach production, and a bad branch can simply be reset.

flowchart TD
  A["git push"] --> B{"Production<br/>or PR?"}
  B -->|"main branch"| C["Production deploy"]
  B -->|"pull request"| D["Deploy preview"]
  C --> E["Migrations applied<br/>just before publish"]
  E --> F[("Production<br/>database")]
  D --> G["New DB branch<br/>copy of prod data"]
  G --> H["Migrations applied<br/>before preview is live"]
  H --> I[("Preview branch<br/>isolated")]
  I -.->|"reset or discard<br/>no prod impact"| G

This is the same safety model Deploy Previews gave your code, extended to your data — which is also why each Agent Runner run gets its own branch: an agent can rewrite the schema and delete rows without any risk to the live site.

When to reach for it

Situation Pick
App already hosted on Netlify, wants Postgres with zero setup Netlify Database
Need per-preview isolated data for PRs or agent runs Netlify Database (branching is the differentiator)
Need row-level security tied to an auth provider, storage, realtime Supabase (see supabase/)
Need Postgres decoupled from the host, or used from Vercel Neon directly (see neon/)
Key/value or unstructured blobs, not relational data Netlify Blobs

Prerequisites

Setup

The fastest path on an existing project is the interactive initialiser:

netlify database init          # interactive
netlify database init --yes    # non-interactive (CI / AI agents)

It installs @netlify/database, lets you pick a query style (raw SQL or Drizzle ORM), scaffolds a starter migration, optionally seeds sample data, and verifies the database is reachable.

To wire it up manually instead:

npm install @netlify/database

Then write your first migration under netlify/database/migrations/, add a function that queries it, and deploy — Netlify provisions the database and applies the migration as part of the deploy lifecycle.

Gotcha — provisioning is triggered by the package. If @netlify/database is not installed in the project, Netlify will not auto-provision a database. Either install it, or create the database by hand in the UI under Data & Storage → Database.

Querying with @netlify/database

getDatabase() returns a client already configured for wherever the code is running — it selects a different underlying connector for builds and long-running servers than it does for Functions and Edge Functions. That is why it works in an edge function, where a raw pg client would not.

import { getDatabase } from "@netlify/database";

const db = getDatabase();

// Tagged template — interpolated values are ALWAYS parameterized (no SQL injection)
const userId = 42;
const users = await db.sql`SELECT * FROM users WHERE id = ${userId}`;

// Type the returned rows
interface User { id: number; name: string; email: string }
const typed = await db.sql<User>`SELECT id, name, email FROM users`;

// Stream large result sets instead of buffering them
for await (const chunk of db.sql`SELECT * FROM orders`.chunked(100)) {
  console.log(`Processing ${chunk.length} rows`);
}

The sql tagged template is based on Waddler. A SQLTemplate is thenable and also exposes execute(), stream(), chunked(size), and toSQL() (returns the SQL string + params without running it — handy for debugging).

Helpers on db.sql:

Helper Purpose
sql.identifier(v) Safely quote a dynamic table/column name
sql.values(rows) Build a bulk VALUES list from a 2-D array
sql.default The SQL DEFAULT keyword, for inserts
sql.raw(v) Inject an unparameterized fragment — bypasses injection protection
sql.unsafe(q, params?) Run a raw query string with positional $1 params

Security: sql.raw() is the one escape hatch that will happily interpolate attacker-controlled input into SQL. Never pass user input to it — use sql.identifier() for dynamic table/column names and ordinary ${} interpolation for values.

Transactions

db.sql does not pin a connection, so BEGIN/COMMIT must run on a single client from the pool. db.pool is a standard pg.Pool:

import { getDatabase } from "@netlify/database";

const db = getDatabase();
const client = await db.pool.connect();

try {
  await client.query("BEGIN");
  await client.query("INSERT INTO users (name, email) VALUES ($1, $2)", ["Ada", "ada@example.com"]);
  await client.query("INSERT INTO posts (author_id, title) VALUES ($1, $2)", [1, "First post"]);
  await client.query("COMMIT");
} catch (e) {
  await client.query("ROLLBACK");
  throw e;
} finally {
  client.release();   // always release, or the pool leaks connections
}

Migrations

Migrations live in netlify/database/migrations/ — either as flat .sql files or as one subdirectory per migration containing migration.sql:

netlify/database/migrations/
├── 20260301143000_create_users.sql
├── 20260318091500_add_posts.sql
└── 20260425103000_create_comments.sql

Names must match <number>_<slug>: digits setting the order, then a slug of lowercase letters, digits, hyphens, and underscores. Migrations are sorted lexicographically, which is why timestamp prefixes are safer than hand-numbered ones once more than one person (or agent) is adding them.

Netlify applies them automatically:

Because they run immediately before the new code goes live, the window where old code meets a new schema is small — but it is not zero. Write backwards-compatible migrations. For breaking changes use the expand-and-contract pattern: add the new column alongside the old and write to both, backfill, then drop the old one in a later migration.

Gotcha — the directory is magic. Anything in netlify/database/migrations/ is auto-applied. If you bring your own migration tool (Prisma Migrate, Atlas, raw scripts), point it at a different directory or Netlify will run those files too.

CLI reference

Every command takes --json for structured output, which is what makes this surface agent-friendly:

netlify database status                    # enabled? package installed? applied + pending migrations
netlify database status --branch my-feat   # target a remote branch instead of local
netlify database status --show-credentials # include the full connection string

netlify database connect                             # interactive SQL REPL
netlify database connect --query "SELECT * FROM users"
netlify database connect --json                      # print connection details as JSON

netlify database migrations new --description "add users table" --scheme timestamp
netlify database migrations apply            # apply pending migrations locally
netlify database migrations apply --to 0003  # apply up to a specific migration
netlify database migrations pull             # overwrite local files from a remote branch
netlify database migrations reset            # delete local, unapplied migration files

netlify database reset                       # wipe the LOCAL dev database only

netlify database reset and netlify database migrations reset only ever touch the local development database — they cannot damage production or a preview branch.

Local development

netlify dev starts a real Postgres-compatible database on your machine and shuts it down with the dev server — there is no Docker container or local Postgres install to manage:

netlify dev
netlify database migrations apply     # the local DB does NOT auto-apply migrations

Vite projects can get the same emulated environment without netlify dev by adding @netlify/vite-plugin. Both use the same engine, so data and migrations are interchangeable between them.

Connect any Postgres tool (psql, TablePlus, DataGrip) while it runs:

psql "$(netlify database connect --json | jq -r .connection_string)"

For integration tests, @netlify/database-dev exposes the emulator as a library (new NetlifyDB() → start() / applyMigrations(dir) / stop()), defaulting to in-memory on a random port. Use @netlify/dev when a test needs the whole Netlify runtime, not just the database.

Differences from production worth knowing: it is a single local process (not a load-testing target), branching is a deploy-time concept so locally there is exactly one database, and auto-scale/sleep settings do not apply.

Bringing your own driver or ORM

The connection string is available two ways — getConnectionString() from @netlify/database, or the NETLIFY_DB_URL environment variable, which is injected into builds, agent runners, functions, and edge functions.

import { getConnectionString } from "@netlify/database";
import pg from "pg";

const pool = new pg.Pool({ connectionString: getConnectionString() });
const { rows } = await pool.query("SELECT * FROM users");

Drizzle ORM has a native adapter. Install from the beta tag (these become 1.0 shortly and carry a better migration format), and point Drizzle Kit's output at Netlify's migrations directory or the automatic runner will never see them:

npm install @netlify/database drizzle-orm@beta
npm install -D drizzle-kit@beta
// drizzle.config.ts
export default defineConfig({
  dialect: "postgresql",
  schema: "./db/schema.ts",
  out: "netlify/database/migrations",   // ← not the default "drizzle"
});
// db/index.ts — the connection is configured automatically
import { drizzle } from "drizzle-orm/netlify-db";
import * as schema from "./schema";

export const db = drizzle({ schema });

Scaling, sleep, and cost

Compute is metered in database compute units — one unit = 25% of a vCPU + 1 GB RAM. Auto-scale sets a min/max the database moves between; sleep on inactivity (default: after 5 minutes idle) pauses it so an idle database stops consuming credits.

Meter Cost
Database compute 10 credits per compute unit
Database bandwidth (data out) 20 credits per GB
Storage Free until 1 July 2026, then billed at rates announced in advance

Selected plan limits (the full table is in the billing docs):

Limit Free Personal Pro Enterprise
Databases per account 3 5 50 500
Branches per database 20 100 300 450
Max compute units 1 4 16 32
Max sleep-on-inactivity 5 min 5 min Always on Always on
Storage per database 5 GB 100 GB 100 GB No limit
Bandwidth per billing period 5 GB 100 GB 100 GB No limit

REST API

All endpoints are site-scoped, rooted at https://api.netlify.com/api/v1, and authenticate via OAuth 2:

Endpoint Purpose
POST / GET /sites/{site_id}/database Create or read the database (returns connection_string)
POST /sites/{site_id}/database/branch Create a branch for a deploy_id
GET / DELETE /sites/{site_id}/database/branch/{deploy_id} Read or delete a deploy's branch
POST /sites/{site_id}/database/snapshot Point-in-time snapshot (defaults to production)
GET /sites/{site_id}/database/snapshots List snapshots
POST /sites/{site_id}/database/snapshot/{id}/restore Restore a snapshot to a branch

codeAmani notes


Edge Network

Every deploy is published to Netlify's global edge network — a CDN with atomic deploys: a deploy either goes live completely or not at all, and publishing automatically invalidates the cache for changed content. There is no manual purge step in the normal workflow.

Cache-control headers

Netlify honours three cache-control fields, most specific wins:

Header Applies to Precedence
Netlify-CDN-Cache-Control Netlify's CDN only Highest
CDN-Cache-Control Any CDN that supports it Middle
Cache-Control Browsers and CDNs Lowest (fallback)

Use Netlify-CDN-Cache-Control to cache aggressively at the edge while keeping browsers on a short leash:

return new Response(body, {
  headers: {
    "Netlify-CDN-Cache-Control": "public, durable, s-maxage=86400, stale-while-revalidate=604800",
    "Cache-Control": "public, max-age=0, must-revalidate",   // browser always revalidates
  },
});

Two directives are worth knowing:

Cache key variation with Netlify-Vary

Netlify-Vary controls what makes a request a different cache entry — finer-grained than the standard Vary header:

Netlify-Vary: query=page|per_page, country=ke|us, language=en|sw, cookie=is_logged_in
Instruction Varies on
query / query=a|b All query params, or only the listed ones
header=X|Y Named request headers
language=en|sw Accept-Language, honouring quality values
country=ke|us Geo-IP country
cookie=key Named cookies

Rules that bite:

High-Performance Edge (Enterprise)

An Enterprise-only upgrade to the standard network: 70+ global points of presence with dynamic PoP adjustment, a dedicated Site Reliability Engineer, 24×7×365 incident response, and proactive DDoS protection. Netlify quotes up to 50% faster than their standard network (and up to 300% faster than a traditional monolith). Relevant only if a client is on Enterprise — the standard edge is what codeAmani projects actually run on.

codeAmani notes


AI Gateway

AI Gateway lets project code call OpenAI, Anthropic, Google Gemini, OpenRouter, and TypeSafe AI models with no API keys of your own. Netlify injects both the API key and a provider-specific base URL into every compute context, proxies the call, and bills the token usage to your Netlify credits.

How it works

When a Function or Edge Function initialises, Netlify sets these — but never overrides a value you have already set at project or team level:

Provider Injected variables
OpenAI OPENAI_API_KEY, OPENAI_BASE_URL
Anthropic ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL
Google Gemini GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL
OpenRouter OPENROUTER_API_KEY, OPENROUTER_BASE_URL
TypeSafe AI TYPESAFE_API_KEY, TYPESAFE_BASE_URL

NETLIFY_AI_GATEWAY_KEY and NETLIFY_AI_GATEWAY_URL are always injected and never collide with the above — use them when you deliberately mix your own keys with Netlify's, or want to be explicit about which path a call takes.

Because the official SDKs read these variables by default, the code is just the SDK with no configuration:

// netlify/functions/summarise.ts
import Anthropic from "@anthropic-ai/sdk";
import type { Config, Context } from "@netlify/functions";

// No apiKey argument — ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL are injected.
const anthropic = new Anthropic();

export default async (req: Request, context: Context) => {
  const { text } = await req.json();

  const message = await anthropic.messages.create({
    model: "claude-sonnet-4-5-20250929",
    max_tokens: 1024,
    messages: [{ role: "user", content: `Summarise in two sentences:\n\n${text}` }],
  });

  return Response.json({ summary: message.content });
};

export const config: Config = { path: "/api/summarise" };

Framework server code (Next.js, Astro, Nuxt, TanStack Start, …) is packaged into Functions at build time, so the same variables are available there with no extra setup. The OpenRouter SDK needs v1.2.43 or later.

Requirements and gotchas

Cost and rate limits

Token usage is converted to USD at published provider rates, then to credits: $1 USD of model usage = 180 credits. Rate limits are per minute, per team, across all projects:

Plan Credits / minute
Free 90
Personal 450
Pro 1,800
Enterprise 9,000

Current limitations: context window capped at 200k tokens; Anthropic prompt caching is limited to the default 5-minute ephemeral cache; Gemini explicit context caching is unsupported; request headers are not passed through (so header-gated experimental features are unavailable); no batch inference; no OpenAI priority processing.

codeAmani notes


Agent Runners

Agent Runners run a coding agent inside Netlify's infrastructure, prompted from the dashboard (or a phone) rather than a local terminal. The agent gets the project's repo, environment variables, build settings, and deploy pipeline, and ships its work to a deploy preview for review.

Supported agents: Claude Code, OpenAI Codex, Google Gemini, and OpenCode. Each run's model and reasoning effort are configurable per agent, and those settings are a personal preference — they do not apply team-wide. OpenCode is served via OpenRouter and Netlify routes only to providers with a Zero Data Retention policy; the other three run on their vendors' own models.

Available on credit-based plans (Free, Personal, Pro); Enterprise teams go through their account manager.

flowchart LR
  A["Prompt from<br/>dashboard or phone"] --> B["Agent run<br/>Claude Code / Codex / Gemini / OpenCode"]
  B --> C["Own database branch<br/>+ deploy preview"]
  C --> D{"Review"}
  D -->|"Approve"| E["Publish to production"]
  D -->|"Reject"| F["Discard — production untouched"]

The pairing with Netlify Database is the point: every run gets its own database branch, so an agent can add tables, write migrations, and mutate data with no path to production until a human publishes.

Good fits: well-defined backlog items, broken links and redirects, copy and content updates from non-engineers, landing/404/maintenance pages, scaffolding a platform primitive, and on-the-go fixes. Poor fits: anything needing deep local iteration, a debugger, or judgement about architecture.

codeAmani notes


Observability

Analytics & metrics → Observability gives near-real-time visibility into production: requests, bandwidth, runtime behaviour, Functions, and Edge Functions. It answers "what is actually happening on the site right now", and it replaces Function Metrics on credit-based plans.

Retention depends on plan:

Plan Time window
Free / Personal Past 24 hours
Pro Past 7 days
Enterprise Past 30 days

Quick actions apply pre-built filter sets across three axes:

Axis Answers
Traffic Top URLs, top 404s, top URLs with errors, client types, top AI-crawler searches, browser-only traffic
Bandwidth Bandwidth by URL, by client type, by content type
Compute Most-invoked functions, slowest URLs, which clients drive function usage

The rest of the monitoring surface sits alongside it:

Tool Use
Log drains Stream deploy/function/traffic logs to an external sink (Datadog, S3, …)
Logs Per-deploy and per-function logs in the dashboard
Real User Monitoring Field performance data from actual visitors
Lighthouse Scores generated as part of the build
Notifications Deploy/build events to Slack, email, or webhooks
Split testing Branch-based A/B at the edge

Observability does not show credit usage. Spend lives under billing — monitor usage for credit-based plans. Two different questions, two different screens.

codeAmani notes


Security

Netlify's security surface splits into three areas: access to your sites, access to Netlify itself, and platform-level protections.

Secure access to sites

Control What it does
Password protection Single shared password on a site or deploy preview
Project visibility Public / private project listings
Firewall traffic rules Allow or block by IP address or geography
Rate limiting rules Per-visitor request caps (use these in front of AI Gateway functions)
Web Application Firewall Managed rule sets against common attack traffic
Role-based access control Per-role permissions on site access
Basic auth via custom headers Credentials enforced at the edge

Secure access to Netlify

SAML SSO through an identity provider, SCIM directory sync for provisioning, enforced 2FA, role-based access control, and the Secrets Controller — an enhanced policy for the most sensitive environment variables that blocks them from being exposed in builds and adds secret scanning. Netlify also scans deploys for leaked secrets and can fail the build when it finds one.

Platform protections

Proactive DDoS monitoring with automatic detection, rate limiting, and client blocking; global load balancing; AES-256 (or stronger) encryption at rest; TLS 1.2+ in transit; Content Security Policy support; log drains; and Private Connectivity for Enterprise. Enterprise teams also get a Security Scorecard that grades the team's posture.

Compliance

SOC 2 Type 2 and ISO 27001 reports, PCI DSS, GDPR and CCPA — current details live at the Netlify trust center.

Careful — platform PCI DSS does not extend to Netlify Database. The hosting platform carries PCI DSS, but Database Services are explicitly not PCI-DSS certified and are not HIPAA-eligible by default. Hosting a payment page on Netlify is fine; writing cardholder data into Netlify Database is not. See the Netlify Database compliance notes.

codeAmani notes


Common Use Cases

Use Case Approach
Deploy on merge GitHub Actions + netlify deploy --prod
Preview URLs for PRs netlify deploy (no --prod) in PR workflow
Edge function auth netlify/edge-functions/ directory
Form submissions Netlify Forms + MCP netlify-project-services-reader
Environment management MCP netlify-project-services-updater / CLI env:set
Extension management MCP netlify-extension-services-updater
Relational data Netlify Database — netlify database init, query via @netlify/database
Safe schema changes Migrations in netlify/database/migrations/, auto-applied per deploy
Isolated data per PR Deploy previews get their own DB branch automatically — no setup
Local DB for dev/tests netlify dev (bundled Postgres) or @netlify/database-dev in tests
LLM calls without keys AI Gateway — official SDK with no apiKey, billed to Netlify credits
Agent-driven small fixes Agent Runners from the dashboard; each run gets its own DB branch + preview
Fast pages on 2G/3G Netlify-CDN-Cache-Control: durable, stale-while-revalidate=...
Per-country cached pages Netlify-Vary: country=ke|us instead of uncached personalisation
Find slow / erroring routes Observability → Quick actions → Compute or Traffic insights
Cap per-visitor AI spend Rate limiting rules on the function that calls AI Gateway

Troubleshooting

Issue Fix
netlify: command not found npm install -g netlify-cli
Not linked to a site Run netlify link in project root
Build fails in CI Check NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID secrets
Edge function not triggering Verify config.path matches the route
Function hits the 60s timeout Timeout is fixed (not configurable) — move long work to a background (-background) or scheduled function
No database provisioned on deploy @netlify/database must be installed, or create it manually under Data & Storage → Database
netlify database command not found Needs Netlify CLI ≥ 26.0.0 and Node ≥ 20.12.2 — npm install -g netlify-cli
Drizzle migrations never run Set out: "netlify/database/migrations" in drizzle.config.ts (default drizzle dir is ignored)
Local queries hit an empty schema The local DB does not auto-migrate — run netlify database migrations apply
First request after idle is slow Sleep-on-inactivity (5 min default); Pro/Enterprise can set the database always-on
AI Gateway vars missing / OPENAI_BASE_URL undefined Needs a credit-based plan and at least one production deploy
AI calls bypass the Gateway unexpectedly You set that provider's key yourself — Netlify never overrides it; unset it or use NETLIFY_AI_GATEWAY_*
AI Gateway 429s Team-wide per-minute credit limit (Free 90 / Personal 450 / Pro 1,800); add rate-limiting rules
Netlify-Vary seems ignored First response cached for that URL wins; also unsupported under basic auth and On-demand Builders
Stale content after deploy Atomic deploys auto-invalidate — check for a manual durable policy or an upstream CDN (Cloudflare)
Observability data has vanished Retention is 24h on Free/Personal, 7d Pro, 30d Enterprise — use log drains to keep history

netlify.toml example:

[build]
  command = "npm run build"
  publish = "dist"

[functions]
  node_bundler = "esbuild"

[[edge_functions]]
  path = "/api/*"
  function = "auth"

Official docs:

Networking Integration Guide

Technology: networking · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/networking

Insight:

Networking is the layer every codeAmani deploy silently depends on and nobody owns: a name resolves, a socket opens, a certificate validates, bytes move. The trade-off is that almost all of it is someone else's infrastructure — DNS caches you cannot flush, CAs you do not run, middleboxes you cannot see — so the practical skill is diagnosis, not construction. Get DNS and TLS right and the Porkbun → Cloudflare → Vercel path is boring; get them wrong and every other layer reports a lie.

███╗   ██╗███████╗████████╗██╗    ██╗ ██████╗ ██████╗ ██╗  ██╗██╗███╗   ██╗ ██████╗
████╗  ██║██╔════╝╚══██╔══╝██║    ██║██╔═══██╗██╔══██╗██║ ██╔╝██║████╗  ██║██╔════╝
██╔██╗ ██║█████╗     ██║   ██║ █╗ ██║██║   ██║██████╔╝█████╔╝ ██║██╔██╗ ██║██║  ███╗
██║╚██╗██║██╔══╝     ██║   ██║███╗██║██║   ██║██╔══██╗██╔═██╗ ██║██║╚██╗██║██║   ██║
██║ ╚████║███████╗   ██║   ╚███╔███╔╝╚██████╔╝██║  ██║██║  ██╗██║██║ ╚████║╚██████╔╝
╚═╝  ╚═══╝╚══════╝   ╚═╝    ╚══╝╚══╝  ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝

Networking Integration Guide

Focus: The working model of TCP/IP, addressing, DNS, TLS and HTTP/2–3 that an application developer actually needs — plus the diagnostic commands that prove which layer is lying, and the defensive posture for network-layer risk (open ports, weak TLS, DNS hijacking, SSRF, MITM).

Overview

This is a topic guide, not an SDK. There is nothing to npm install; the deliverable is a mental model plus a toolbox. Reach for it when a deploy "works locally", when a domain resolves for you but not for your users, when a certificate is valid in the browser but rejected by curl, or when a webhook receiver needs to make an outbound call to a URL it did not choose.

Two models describe the same stack. The OSI seven-layer model is the vocabulary (people say "layer 7" and "layer 4"); the TCP/IP four-layer model is what actually ships. Use OSI to talk, TCP/IP to debug.

TCP/IP layer OSI layers What lives here What breaks
Link 1–2 Physical, Data Link Ethernet, Wi-Fi, MAC addresses, ARP Cable/radio, MTU, local LAN only
Internet 3 Network IPv4/IPv6, ICMP, routing, NAT Wrong route, firewall drop, no IPv6
Transport 4 Transport TCP (ordered, reliable), UDP (datagram — carries QUIC) Port closed, RST, SYN timeout
Application 5–7 Session, Presentation, Application DNS, TLS, HTTP/1.1, HTTP/2, HTTP/3 Bad record, expired cert, 4xx/5xx
flowchart LR
  A["Browser<br/>https://app.example.com"] --> B["DNS resolve<br/>A / AAAA / CNAME"]
  B --> C["TCP :443<br/>or QUIC over UDP :443"]
  C --> D["TLS handshake<br/>SNI · ALPN · cert chain"]
  D --> E{"ALPN negotiated"}
  E -->|"h2"| F["HTTP/2<br/>multiplexed over one TCP conn"]
  E -->|"h3"| G["HTTP/3<br/>multiplexed over QUIC streams"]
  E -->|"http/1.1"| H["HTTP/1.1<br/>one request per connection"]
  F --> I["CDN / edge PoP"]
  G --> I
  H --> I
  I -->|"cache HIT"| J["Response from edge"]
  I -->|"cache MISS"| K["Origin<br/>Vercel function · Supabase · Neon"]

Every arrow in that diagram is a place a request can die, and each one has a distinct symptom. The rest of this guide walks them in order.

Official Documentation

Resource URL
MDN — HTTP overview https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview
MDN — Transport Layer Security https://developer.mozilla.org/en-US/docs/Web/Security/Transport_Layer_Security
MDN — Connection management in HTTP/1.x https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Connection_management_in_HTTP_1.x
RFC 9293 — TCP https://www.rfc-editor.org/rfc/rfc9293
RFC 1035 — DNS implementation and specification https://www.rfc-editor.org/rfc/rfc1035
RFC 8499 — DNS terminology https://www.rfc-editor.org/rfc/rfc8499
RFC 8446 — TLS 1.3 https://www.rfc-editor.org/rfc/rfc8446
RFC 9110 — HTTP semantics https://www.rfc-editor.org/rfc/rfc9110
RFC 9113 — HTTP/2 https://www.rfc-editor.org/rfc/rfc9113
RFC 9000 — QUIC transport https://www.rfc-editor.org/rfc/rfc9000
RFC 9114 — HTTP/3 https://www.rfc-editor.org/rfc/rfc9114
RFC 1918 — Private IPv4 address space https://www.rfc-editor.org/rfc/rfc1918
RFC 4632 — CIDR https://www.rfc-editor.org/rfc/rfc4632
RFC 8659 — CAA records https://www.rfc-editor.org/rfc/rfc8659
IANA — Service names and port numbers https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml
OWASP — SSRF prevention cheat sheet https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
OWASP — Transport layer security cheat sheet https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html
Mozilla SSL Configuration Generator https://ssl-config.mozilla.org/
BIND 9 manpages (dig, nslookup) https://bind9.readthedocs.io/en/latest/manpages.html
curl manual https://curl.se/docs/manpage.html
Nmap — legal issues https://nmap.org/book/legal-issues.html

Setup — the diagnostics toolbox

Install the tools first; every section below assumes them.

# Debian / Ubuntu / WSL
sudo apt update && sudo apt install -y dnsutils curl iproute2 net-tools traceroute nmap openssl

# macOS (Homebrew) — dig/host/nslookup come from the bind formula
brew install bind curl nmap openssl@3 mtr
# Windows — nslookup, tracert and netstat ship with the OS.
# The PowerShell equivalents are richer and script better:
Resolve-DnsName tech-stack.codeamanilabs.org -Type A
Test-NetConnection tech-stack.codeamanilabs.org -Port 443
Get-NetTCPConnection -State Listen | Sort-Object LocalPort

Optional environment variables the examples use:

# .env.local — used by the SSRF-guard example below. Server-side only.
OUTBOUND_URL_ALLOWLIST=api.stripe.com,sandbox.safaricom.co.ke,api.safaricom.co.ke

apt install nmap is fine; running nmap is not automatically fine. See Scanning, authorized only.


IP addressing, CIDR, subnets, NAT

An IPv4 address is 32 bits (203.0.113.10); IPv6 is 128 bits (2001:db8::1). CIDR notation (RFC 4632) appends a prefix length: the first N bits are the network, the rest identify hosts inside it.

CIDR Netmask Usable host addresses (IPv4) Typical use
/32 255.255.255.255 1 A single host — firewall rules, allowlists
/24 255.255.255.0 254 One small subnet / VPC subnet
/16 255.255.0.0 65,534 A VPC
/8 255.0.0.0 16,777,214 10.0.0.0/8 private space
/0 0.0.0.0 everything Default route; "any source" in a security group

Two addresses in every IPv4 subnet are not usable hosts — the all-zeros network address and the all-ones broadcast address — which is why a /24 gives 254, not 256.

Ranges that must never be reachable from user input

These are the ranges an SSRF guard denies. Memorise them; they show up again in the security section.

Range RFC Meaning
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 RFC 1918 Private IPv4
127.0.0.0/8, ::1/128 — Loopback
169.254.0.0/16, fe80::/10 RFC 3927 Link-local — includes 169.254.169.254, the cloud metadata endpoint
100.64.0.0/10 RFC 6598 Carrier-grade NAT shared space
fc00::/7 RFC 4193 IPv6 unique local addresses
0.0.0.0/8 — "This network"; 0.0.0.0 often resolves to localhost

NAT

Network Address Translation lets many private addresses share one public address by rewriting source IP + port and keeping a translation table. Consequences that matter to application code:


Ports and sockets

A socket is the five-tuple {protocol, source IP, source port, destination IP, destination port}. A server binds and listens on a well-known port; a client connects from an ephemeral port. IANA divides the space into System (0–1023), User/Registered (1024–49151) and Dynamic/ephemeral (49152–65535, never assigned).

Port Service Note
22 SSH Never expose with password auth; keys only
25 / 465 / 587 SMTP / SMTPS / submission 587 is the modern submission port; many hosts block 25 outbound
53 DNS UDP first, TCP fallback for large responses (RFC 7766)
80 HTTP Keep it open only to 301 to HTTPS and to serve ACME HTTP-01
443 HTTPS TCP for HTTP/1.1 + HTTP/2, UDP for HTTP/3/QUIC
853 DNS-over-TLS RFC 7858
3000 / 3001 Next.js dev Local only
5432 Postgres Supabase / Neon — never open to 0.0.0.0/0
6379 Redis Historically unauthenticated by default; treat as internal-only

Two rules that prevent most self-inflicted outages:

  1. Bind to 127.0.0.1 for anything that does not need to be public. A service bound to 0.0.0.0 on a cloud VM is on the internet the moment the security group allows it.
  2. Open ports are inventory. You cannot defend a listener you did not know existed — see the ss recipe below.

DNS

DNS turns a name into an address (RFC 1035; terminology in RFC 8499). Four roles are involved, and confusing them is the root of most "it works for me" reports.

flowchart TD
  A["Application<br/>getaddrinfo / fetch"] --> B["Stub resolver<br/>OS cache"]
  B --> C["Recursive resolver<br/>ISP · 1.1.1.1 · 8.8.8.8<br/>owns the TTL cache"]
  C -->|"cache miss"| D["Root servers<br/>. → 'ask .org'"]
  D --> E["TLD servers<br/>.org → 'ask ns1.porkbun.com'"]
  E --> F["Authoritative NS<br/>your zone — the only source of truth"]
  F -->|"answer + TTL"| C
  C -->|"cached answer"| B
  B --> A

The authoritative nameserver is the only place a record actually changes. Everything between it and the user is a cache. "DNS propagation" is not a push — it is the world's recursive resolvers expiring their cached copy after the record's TTL elapses. That is the whole mechanism, and it dictates the operational rule below.

Record types

Type Points at Notes
A IPv4 address Apex domains on Vercel use an A record
AAAA IPv6 address Add it — a growing share of mobile networks are IPv6-only with NAT64
CNAME Another name Cannot coexist with other records on the same name, and cannot legally sit at the zone apex. Providers work around this with CNAME flattening / ALIAS / ANAME
MX Mail exchanger + priority Lower priority number wins (RFC 5321)
TXT Arbitrary text Domain-ownership proofs, SPF (RFC 7208), DKIM (RFC 6376), DMARC (RFC 7489)
CAA Which CAs may issue for this name RFC 8659 — issue / issuewild / iodef
NS Delegation to authoritative servers Changing these at the registrar moves the whole zone
SOA Zone metadata Serial, refresh, and the negative-cache TTL
SRV Service host and port Used by protocols that need port discovery
HTTPS / SVCB Connection hints before the first request RFC 9460 — lets a client learn ALPN (h3), IP hints and port up front
PTR Reverse: address → name Lives in the provider's zone, not yours

Two TXT records everyone gets wrong: SPF must be one TXT record per domain (multiple v=spf1 records is a permanent error), and DMARC lives at _dmarc.example.com, not the apex.

The TTL rule

Lower the TTL before you change anything.

# 1. Days before a cutover: drop TTL to 300s on the records you will change,
#    then wait for the OLD TTL to fully elapse.
# 2. Make the change.
# 3. Verify from multiple resolvers.
# 4. Days later, once stable, raise TTL back to 3600+.

You cannot shorten a TTL retroactively: a resolver that cached the old record at 86400s will hold it for up to a day no matter what you do afterwards. This is why a rushed DNS cutover is a multi-hour outage and a planned one is invisible.

Diagnosing DNS

# What does the authoritative server say? (bypasses every cache — the ground truth)
dig +short NS codeamanilabs.org
dig @ns1.porkbun.com tech-stack.codeamanilabs.org A

# What does the world see? Query specific public resolvers.
dig @1.1.1.1 tech-stack.codeamanilabs.org A +short
dig @8.8.8.8 tech-stack.codeamanilabs.org A +short

# Full delegation path, root → TLD → authoritative
dig +trace tech-stack.codeamanilabs.org

# Remaining TTL on the cached answer (run twice — it counts down)
dig tech-stack.codeamanilabs.org A | grep -A1 "ANSWER SECTION"

# Other record types
dig codeamanilabs.org MX +short
dig codeamanilabs.org CAA +short
dig _dmarc.codeamanilabs.org TXT +short

# nslookup — everywhere, including bare Windows
nslookup -type=A tech-stack.codeamanilabs.org 1.1.1.1
# Windows PowerShell equivalents
Resolve-DnsName tech-stack.codeamanilabs.org -Type A -Server 1.1.1.1
Resolve-DnsName codeamanilabs.org -Type MX
Clear-DnsClientCache   # flushes the local stub cache only, not the recursive resolver

If dig @<authoritative-ns> is right but dig @1.1.1.1 is wrong, you are waiting on TTL — do nothing. If the authoritative answer itself is wrong, fix the record. That single test separates "be patient" from "act", and it is the most useful thing in this guide.


TLS and HTTPS

TLS 1.3 (RFC 8446) authenticates the server, negotiates keys, and encrypts everything after. The handshake is one round trip before application data in the full case; a resumed session can send 0-RTT early data (with replay caveats — never put a non-idempotent request in 0-RTT).

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: TCP SYN → SYN/ACK → ACK
  C->>S: ClientHello (SNI=app.example.com, ALPN=[h2,http/1.1], key_share)
  S->>C: ServerHello (key_share) + {Certificate, CertificateVerify, Finished}
  Note over C,S: Client validates chain to a trusted root,<br/>checks hostname against SAN, checks validity dates
  C->>S: {Finished}
  C->>S: {HTTP request} — encrypted
  S->>C: {HTTP response} — encrypted

Three fields in ClientHello do a lot of work:

Certificates: what validation actually checks

A certificate is trusted only if all of these hold. Any one failing is a hard error, and the error message rarely says which.

  1. Chain of trust — leaf → intermediate(s) → a root in the client's trust store. The single most common production TLS bug is a server that serves the leaf but omits the intermediate: browsers often paper over it with AIA fetching, curl and mobile SDKs do not. Symptom: "works in Chrome, fails in the app".
  2. Hostname match — the requested name must appear in the certificate's Subject Alternative Name list (RFC 9525). The legacy Common Name field is no longer used for matching.
  3. Validity window — notBefore ≤ now ≤ notAfter. Expiry is an outage, so renewal must be automated.
  4. Revocation / CT — modern clients also expect Certificate Transparency signatures.
# Full handshake detail: chain, SANs, protocol, cipher
openssl s_client -connect tech-stack.codeamanilabs.org:443 \
  -servername tech-stack.codeamanilabs.org -showcerts </dev/null

# Just the dates and names
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

# Does the chain validate standalone? (no browser AIA rescue)
curl -vI https://example.com 2>&1 | grep -Ei "SSL|subject|issuer|ALPN|HTTP/"

# Prove a specific TLS version is or is not accepted
curl --tlsv1.3 --tls-max 1.3 -sI https://example.com -o /dev/null -w "%{http_version} %{ssl_verify_result}\n"

Hardening checklist


HTTP/1.1, HTTP/2, HTTP/3

HTTP/1.1 HTTP/2 (RFC 9113) HTTP/3 (RFC 9114)
Transport TCP TCP QUIC over UDP (RFC 9000)
Framing Text Binary frames Binary frames on QUIC streams
Concurrency One request in flight per connection; browsers open ~6 Multiplexed streams on one connection Multiplexed streams, independent
Head-of-line blocking At the request level Removed at HTTP level, remains at TCP level — one lost segment stalls every stream Removed: a lost packet stalls only its own stream
Header compression None HPACK QPACK
Handshake TCP + TLS separately TCP + TLS separately TLS 1.3 folded into the QUIC handshake
Connection migration No No Yes — connection ID survives an IP change
Discovery default ALPN h2 Alt-Svc: h3=":443" header, or a DNS HTTPS record

HTTP/3 cannot be negotiated in-band on the first connection the way h2 can, because it is a different transport. A client learns about it from an Alt-Svc response header (RFC 7838) or, increasingly, from an HTTPS/SVCB DNS record (RFC 9460) that advertises alpn=h3 before the first packet — saving the initial TCP round trip entirely.

# Which protocol did you actually get?
curl -sI --http2 https://example.com -o /dev/null -w "%{http_version}\n"
curl -sI --http3 https://example.com -o /dev/null -w "%{http_version}\n"   # needs an HTTP/3-capable curl

# Is HTTP/3 advertised?
curl -sI https://example.com | grep -i alt-svc
dig example.com HTTPS +short        # SVCB/HTTPS record, if published

# Timing breakdown — where the milliseconds go
curl -s -o /dev/null -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://example.com

That last command is the highest-value one-liner in this document: it splits a "slow site" complaint into a DNS problem, a TCP-RTT problem, a TLS problem, or an origin problem, in one request.


CDNs and the edge

A CDN terminates TLS at a point of presence close to the user, serves cacheable responses from there, and reuses long-lived warm connections back to origin. The win is not only bytes — it is round trips. On a 3G link with a 200–400 ms RTT, moving the TLS handshake from a US origin to a nearby PoP saves more wall-clock time than any payload optimisation you can make.

# Which PoP answered, and did it cache?
curl -sI https://tech-stack.codeamanilabs.org | grep -Ei "cf-ray|cf-cache-status|x-vercel-cache|age|cache-control"
Header Meaning
x-vercel-cache: HIT / MISS / STALE Vercel edge cache outcome
cf-cache-status: HIT / MISS / DYNAMIC / BYPASS Cloudflare cache outcome
cf-ray: …-NBO Cloudflare ray id; the suffix is the PoP IATA code (NBO = Nairobi)
age Seconds the response has sat in cache

When a name is proxied through Cloudflare (orange cloud), dig returns Cloudflare's anycast address, not your origin — expected, not a misconfiguration. See cloudflare for proxy status and the origin SSL modes; use Full (strict) so the Cloudflare↔origin hop is verified too, otherwise the padlock the user sees covers only half the path.


Diagnostics: which layer is lying?

Work bottom-up. Each command clears one layer, so the first failure localises the fault.

# L3 — is the host routable at all?
ping -c 4 1.1.1.1                 # raw IP: no DNS involved
traceroute -n example.com          # hop-by-hop path; * * * means filtered ICMP, not always a fault
mtr -rw example.com                # traceroute + loss stats over time (best for flaky links)

# L4 — is the port open?
nc -vz example.com 443
# PowerShell: Test-NetConnection example.com -Port 443

# What is listening locally, and which process owns it?
ss -tlnp                           # TCP, listening, numeric, process (needs sudo for other users' procs)
ss -tunap | grep :5432             # every socket touching Postgres
netstat -tulpn                     # older systems; ss is the modern replacement

# L7 — the full story of one request
curl -v https://example.com
curl -sSL -o /dev/null -w "%{http_code} %{num_redirects} %{redirect_url}\n" http://example.com   # follow the redirect chain
Symptom Most likely layer Next command
Could not resolve host DNS dig @1.1.1.1 <name> then dig @<authoritative-ns> <name>
Connection refused Transport — nothing listening ss -tlnp on the host
Connection timed out Firewall / security group silently dropping traceroute, then check the security group
certificate verify failed TLS chain or hostname openssl s_client -showcerts
Works in browser, fails in curl/app Missing intermediate certificate Serve the full chain
Right in dig @ns, wrong for users DNS TTL still counting down Wait; do not re-edit the record
Fast locally, slow in production RTT / cache miss curl -w timing breakdown

Scanning, authorized only

nmap is a legitimate inventory tool for systems you own or have explicit written permission to test. Unauthorized port scanning is unlawful in many jurisdictions and violates most providers' terms of service — read https://nmap.org/book/legal-issues.html before pointing it anywhere.

# Legitimate use: audit YOUR OWN host's exposed surface from outside it.
nmap -Pn -p 1-1024 <your-own-host>          # what does the internet see?
nmap --script ssl-enum-ciphers -p 443 <your-own-host>   # TLS versions/ciphers your server offers

Prefer the alternatives when they exist: ss -tlnp on the box itself is faster, more accurate and needs no permission conversation; your cloud provider's security-group listing is the authoritative answer to "what is exposed".


Network-layer risks and their mitigations

Defensive framing only — each row is a risk you close on your own infrastructure.

Risk Why it happens Mitigation
Open ports / exposed services Service bound to 0.0.0.0; security group left at 0.0.0.0/0 from a debugging session Bind internal services to 127.0.0.1; default-deny inbound; audit with ss -tlnp and the provider's security-group list; put admin surfaces behind a VPN or identity proxy, never behind "an unguessable port"
Weak / misconfigured TLS Legacy protocol versions left enabled; missing intermediate; expired cert TLS 1.2 minimum; config from Mozilla's generator; serve the full chain; automate renewal and alert on notAfter; enable HSTS
DNS hijacking (registrar/zone takeover) Registrar account compromise, or a stale CNAME to a deprovisioned host that an attacker re-claims 2FA + registrar lock on the registrar account (porkbun); publish CAA; delete DNS records when you tear down the resource they point at — dangling CNAMEs are subdomain takeover
DNS spoofing / cache poisoning Plaintext UDP:53 answers are forgeable on a hostile network DNSSEC (RFC 4033) on your zone; DNS-over-HTTPS (RFC 8484) or DNS-over-TLS (RFC 7858) on clients you control; never make a security decision from an unauthenticated DNS answer
SSRF — server fetches an attacker-chosen URL Any endpoint that takes a URL: webhook-target config, image import, link unfurling, "test my callback" buttons Allowlist destination hosts; resolve DNS yourself and reject private/link-local IPs before connecting; refuse redirects or re-validate every hop; block non-http(s) schemes; enforce egress rules at the network. Detail below
MITM on hostile networks Public Wi-Fi, transparent proxies, captive portals HTTPS everywhere + HSTS; never disable certificate verification; treat any request arriving over plain HTTP as untrusted
Credentials on the wire Postgres/Redis exposed publicly; API keys in query strings (they land in every access log) TLS on database connections (sslmode=require or stricter); keys in Authorization headers, never in URLs; rotate on exposure
Amplification / volumetric abuse of your endpoints Any unauthenticated endpoint that does real work Rate limit at the edge; keep expensive routes behind auth; let the CDN absorb L3/L4 volume

SSRF: the pattern that matters for webhook and callback endpoints

The dangerous shape is outbound traffic to a URL a user supplied. From inside a cloud network, http://169.254.169.254/ and http://10.x.x.x/ are reachable and often unauthenticated, so a naïve fetch(userUrl) hands an attacker your internal network.

// lib/safe-fetch.ts — server-side only.
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";

const ALLOWED_HOSTS = new Set(
  (process.env.OUTBOUND_URL_ALLOWLIST ?? "").split(",").map((h) => h.trim()).filter(Boolean),
);

/** RFC 1918 / 3927 / 6598 / 4193 + loopback. Reject, do not "sanitize". */
function isPrivateAddress(ip: string): boolean {
  if (isIP(ip) === 6) {
    const v6 = ip.toLowerCase();
    return v6 === "::1" || v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd");
  }
  const [a, b] = ip.split(".").map(Number);
  if (a === 10 || a === 127 || a === 0) return true;
  if (a === 172 && b >= 16 && b <= 31) return true;
  if (a === 192 && b === 168) return true;
  if (a === 169 && b === 254) return true;          // cloud metadata lives here
  if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
  return false;
}

export async function safeFetch(rawUrl: string, init?: RequestInit): Promise<Response> {
  const url = new URL(rawUrl);

  // 1. Scheme allowlist — blocks file:, gopher:, ftp:, data:
  if (url.protocol !== "https:") throw new Error("only https is allowed");

  // 2. Host allowlist — the strongest control. Prefer it whenever the set is known.
  if (ALLOWED_HOSTS.size > 0 && !ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error(`host not allowed: ${url.hostname}`);
  }

  // 3. Resolve and reject internal addresses (all records, not just the first).
  const resolved = await lookup(url.hostname, { all: true });
  if (resolved.some((r) => isPrivateAddress(r.address))) {
    throw new Error("resolved to a private address");
  }

  // 4. Never follow redirects blindly — a 302 to 169.254.169.254 defeats steps 1-3.
  return fetch(url, { ...init, redirect: "error", signal: AbortSignal.timeout(5_000) });
}

Two honest caveats. First, a DNS rebinding attacker can return a public IP to your lookup() and a private one to the connection that follows; closing that gap requires pinning the validated IP into the connection itself (an undici Agent with a custom connect.lookup). Second, application-level checks are defence in depth — the durable control is network egress policy, so the internal address is unreachable from the request-handling process no matter what the code does. OWASP's cheat sheet is the reference: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html

Inbound webhooks are the mirror-image problem — signature verification, not URL validation. That belongs to webhooks; the broader application-security posture is in security.


codeAmani notes

The deploy path: Porkbun → Cloudflare → Vercel

This is the sequence that actually wires a codeAmani property, and the order matters.

  1. Registrar — the name lives at Porkbun; the porkbun-dns skill edits records programmatically. Registrar lock + 2FA on that account is the root of trust for the entire domain: whoever controls it controls your DNS, and therefore your certificates.
  2. DNS — either Porkbun's nameservers or Cloudflare's. Apex → A record (Vercel shows the value in the project's Domains tab; historically 76.76.21.21); subdomain → CNAME to the per-project Vercel target. CNAME cannot sit at the apex, which is exactly why the apex gets an A.
  3. TLS — issued and renewed automatically by the platform. Publish a CAA record for whichever CA the platform uses, or issuance fails; verify with dig <domain> CAA +short.
  4. Verify before declaring done — dig @<authoritative-ns> for truth, dig @1.1.1.1 for reach, curl -vI for the chain. A green dashboard and a wrong CAA record look identical until the first renewal.

If a name is proxied through Cloudflare in front of Vercel, set SSL mode to Full (strict) — anything less leaves the Cloudflare↔origin hop unverified.

Security

Kenya-targeted projects: the bandwidth angle

On a 2G/3G link, latency and packet loss — not throughput — decide whether an app feels usable. Round trips are the budget.


Troubleshooting

Issue Fix
Record changed but users still see the old value TTL has not expired. dig @<authoritative-ns> to confirm the record is correct, then wait — re-editing resets nothing
CNAME rejected at the apex Not legal in DNS. Use an A record, or the provider's ALIAS/ANAME/CNAME-flattening feature
Certificate issuance fails on a new domain Check dig <domain> CAA +short — a CAA record that omits your platform's CA blocks issuance
curl says certificate verify failed, browser is fine Server omits the intermediate; the browser fetched it via AIA and curl did not. Serve the full chain
ERR_SSL_PROTOCOL_ERROR on a proxied domain Cloudflare SSL mode vs origin mismatch — Flexible in front of an HTTPS origin loops; use Full (strict)
Webhook works in production, never fires locally Your dev box is behind NAT and unroutable. Tunnel it: ngrok http 3000, and register the HTTPS URL
Port shows open with nc but the app 502s Transport is fine, application is not — move up a layer: curl -v and the origin's logs
traceroute shows * * * for the last hops ICMP filtered — normal for cloud hosts, not evidence of a fault. Test the port with nc -vz instead
Two v=spf1 TXT records on one domain Permanent SPF error. Merge into a single record
ss shows nothing but the service "is running" It bound to 127.0.0.1 (or inside a container's namespace) — check the bind address and, for Docker, the port publish flags

Official docs:

Notion Integration Guide

Technology: notion · Category: docs · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/notion

Insight:

Notion is the docs / PM hub — automate status reports, spec-to-ticket conversion, and knowledge capture via the MCP. Great for turning meeting notes into tracked work, but keep customer PII out of pages that sync to external tools (KDPA). Since the 2025-09-03 API version (SDK v5, @notionhq/client ≥5) a database is now a container of one or more data sources — you query dataSources.query({ data_source_id }), not the removed databases.query, and pages parent onto a data_source_id.

███╗   ██╗ ██████╗ ████████╗██╗ ██████╗ ███╗   ██╗
████╗  ██║██╔═══██╗╚══██╔══╝██║██╔═══██╗████╗  ██║
██╔██╗ ██║██║   ██║   ██║   ██║██║   ██║██╔██╗ ██║
██║╚██╗██║██║   ██║   ██║   ██║██║   ██║██║╚██╗██║
██║ ╚████║╚██████╔╝   ██║   ██║╚██████╔╝██║ ╚████║
╚═╝  ╚═══╝ ╚═════╝    ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

Notion Integration Guide

Focus: Automating documentation, project management, and knowledge base workflows from Claude Code using the official Notion MCP server and REST API.

Overview

Notion is an all-in-one workspace for docs, databases, wikis, and project management. The official Notion MCP (hosted at https://mcp.notion.com/mcp, or the local @notionhq/notion-mcp-server) lets Claude Code search pages, create and update content, query data sources, manage tasks, and build automated documentation pipelines — turning Notion into a live integration layer for your development workflow.

Data-source model (API 2025-09-03+, SDK v5 / @notionhq/client ≥5). As of the 2025-09-03 API version a database is a container of one or more data sources (each with its own schema and rows). You now query a data source, not a database: notion.dataSources.query({ data_source_id }) — the old notion.databases.query was removed in SDK v5. notion.databases.retrieve({ database_id }) returns the data_sources[] array so you can resolve a data_source_id. The current latest header is Notion-Version: 2026-03-11. See the 2025-09-03 upgrade guide.

Here is the big picture — your integration token unlocks a clean path from Claude Code to your workspace:

flowchart LR
  CC["Claude Code"] --> MCP["Notion MCP<br/>hosted or REST Client"]
  MCP -->|"Bearer NOTION_TOKEN (ntn_...)"| API["Notion API"]
  API --> Pages["Pages and Blocks"]
  API --> DB["Databases<br/>each holds Data Sources<br/>tasks, sprints, docs"]
  API --> Search["Search and Comments"]

Official Documentation

Resource URL
Notion Developers https://developers.notion.com
Notion API Reference https://developers.notion.com/reference
API versioning https://developers.notion.com/reference/versioning
2025-09-03 upgrade guide (data sources) https://developers.notion.com/docs/upgrade-guide-2025-09-03
Notion MCP (hosted + local) https://developers.notion.com/docs/mcp
Notion MCP Server (local repo) https://github.com/makenotion/notion-mcp-server
JavaScript Client (@notionhq/client) https://github.com/makenotion/notion-sdk-js
Integration Guide https://developers.notion.com/docs/getting-started

MCP Server Setup

Notion now runs a hosted, OAuth-authenticated MCP server — no token juggling, no local process. This is the path Notion recommends; the local repo may be sunset.

# Add the hosted server via Claude Code CLI (Streamable HTTP)
claude mcp add --transport http notion https://mcp.notion.com/mcp

The first tool call opens a browser OAuth flow to authorize the workspace. Endpoints: https://mcp.notion.com/mcp (Streamable HTTP, recommended) or https://mcp.notion.com/sse (SSE).

Option B — Local Notion MCP Server (@notionhq/notion-mcp-server, v2.x)

Use a local integration token when you want a scoped internal integration instead of workspace OAuth.

# Add via Claude Code CLI
claude mcp add notion -- npx -y @notionhq/notion-mcp-server

.mcp.json Configuration

NOTION_TOKEN is the current, recommended way to pass the integration token — OPENAPI_MCP_HEADERS still works for advanced cases:

{
  "mcpServers": {
    "notion": {
      "command": "npx",
      "args": ["-y", "@notionhq/notion-mcp-server"],
      "env": {
        "NOTION_TOKEN": "${NOTION_TOKEN}"
      }
    }
  }
}

Create an internal integration and copy the token (prefix ntn_…) at: https://www.notion.so/profile/integrations

Notion-Version in v2.x. The server sources the Notion-Version header per operation from its OpenAPI spec — most tools use 2025-09-03, and the Markdown page tools use 2026-03-11 — so you no longer hard-code a version. If you do set one via OPENAPI_MCP_HEADERS, your value wins for every tool.

Available MCP Tools (v2.x — data-source model)

Tool names are hyphenated operation IDs (the old notion_* names were dropped in v2.0). Key tools:

Tool Description
search Search pages and data sources (filter values are now ["page", "data_source"])
query-data-source Filter and sort rows in a data source (data_source_id) — replaces post-database-query
retrieve-a-data-source Get a data source's schema / properties
create-a-data-source Create a new data source (parent.page_id)
update-a-data-source Update data source properties
retrieve-a-database Get database metadata including its data_sources[] IDs
retrieve-page-markdown Read a page's content as Markdown (needs 2026-03-11)
update-page-markdown Edit a page's content with Markdown (needs 2026-03-11)
move-page Move a page to a different parent
notion-create-file-upload Start a file upload

Page, block, and comment tools remain (create/retrieve/update a page, append block children, retrieve/create a comment). 22 tools total in v2.x.


REST API Integration

You are just a few calls away from a working task flow. Since the 2025-09-03 API version you resolve a data_source_id from the database once, then query/create against the data source:

sequenceDiagram
  participant App as "Your Code"
  participant N as "Notion Client"
  participant API as "Notion API"
  App->>N: "databases.retrieve(database_id)"
  N->>API: "GET database"
  API-->>N: "data_sources[] → data_source_id"
  App->>N: "dataSources.query filter and sort"
  N->>API: "POST data_sources query"
  API-->>N: "task rows"
  App->>N: "pages.create parent data_source_id"
  N->>API: "POST pages"
  API-->>N: "new page id"
  App->>N: "blocks.children.append notes"
  N->>API: "PATCH blocks"
  API-->>N: "updated page"

JavaScript / TypeScript Client

Requires @notionhq/client v5+ (v5.26.0 at last review) — v5 is the data-source model. On v4 and earlier, databases.query({ database_id }) still exists; the code below targets v5.

npm install @notionhq/client
import { Client } from "@notionhq/client";

// SDK v5 sends a current default Notion-Version; pass notionVersion to pin one
// (e.g. "2026-03-11" for the Markdown page endpoints).
const notion = new Client({ auth: process.env.NOTION_TOKEN });

// Resolve the data source id for a database (single-source DBs use [0]).
const db = await notion.databases.retrieve({
  database_id: process.env.NOTION_TASKS_DB_ID!,
});
const dataSourceId = db.data_sources[0].id;

// Search for pages
const search = await notion.search({
  query: "API Design",
  filter: { property: "object", value: "page" },
});

// Query a data source (e.g., task tracker) — replaces the removed databases.query
const tasks = await notion.dataSources.query({
  data_source_id: dataSourceId,
  filter: {
    and: [
      { property: "Status", select: { equals: "In Progress" } },
      { property: "Assignee", people: { contains: "me" } },
    ],
  },
  sorts: [{ property: "Due Date", direction: "ascending" }],
});

// Create a new page (row in a data source)
const newTask = await notion.pages.create({
  parent: { type: "data_source_id", data_source_id: dataSourceId },
  properties: {
    Name: { title: [{ text: { content: "Fix auth middleware" } }] },
    Status: { select: { name: "Todo" } },
    Priority: { select: { name: "High" } },
    "Due Date": { date: { start: "2026-08-20" } },
  },
});

// Append content to a page
await notion.blocks.children.append({
  block_id: newTask.id,
  children: [
    {
      object: "block",
      type: "heading_2",
      heading_2: { rich_text: [{ text: { content: "Implementation Notes" } }] },
    },
    {
      object: "block",
      type: "paragraph",
      paragraph: { rich_text: [{ text: { content: "Use JWT with RS256 signing." } }] },
    },
    {
      object: "block",
      type: "code",
      code: {
        language: "typescript",
        rich_text: [{ text: { content: "const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });" } }],
      },
    },
  ],
});

Python Client

notion-client v3+ (v3.1.0 at last review) mirrors the JS SDK's data-source model — data_sources.query replaces databases.query.

pip install notion-client
from notion_client import Client
import os

notion = Client(auth=os.environ["NOTION_TOKEN"])

# Resolve the data source id, then query it
db = notion.databases.retrieve(database_id=os.environ["NOTION_TASKS_DB_ID"])
data_source_id = db["data_sources"][0]["id"]

results = notion.data_sources.query(
    data_source_id=data_source_id,
    filter={"property": "Status", "select": {"equals": "Done"}},
)

for page in results["results"]:
    title = page["properties"]["Name"]["title"][0]["text"]["content"]
    print(f"Completed: {title}")

Pagination — querying large databases

Every Notion list/query endpoint (dataSources.query, search, blocks.children.list, users.list, comments.list) is paginated. A single call returns at most one page, so a dataSources.query against a 500-row task tracker will silently give you back only the first slice unless you follow the cursor. Skip this and your "all tasks" report quietly drops everyone past row 100.

The response shape is the same across endpoints:

Field Type Meaning
results array The items in the current page
next_cursor string | null Pass as start_cursor to fetch the next page; null when finished
has_more boolean true if another page exists

Request side: send start_cursor to resume from a cursor, and page_size to control items per page (max 100, default 100). Omit start_cursor for the first page.

Here is the cursor loop — keep going while the server says there is more:

flowchart TD
  Start["start_cursor = undefined"] --> Call["dataSources.query<br/>start_cursor · page_size 100"]
  Call --> Collect["append response.results to rows"]
  Collect --> Check{"has_more"}
  Check -->|"true"| Next["start_cursor = next_cursor"]
  Next --> Call
  Check -->|"false"| Done["return all rows"]

Copy-paste TS loop — fetch every row

import { Client } from "@notionhq/client";
import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

async function queryAllRows(dataSourceId: string) {
  const rows: PageObjectResponse[] = [];
  let cursor: string | undefined = undefined; // undefined → first page

  do {
    const response = await notion.dataSources.query({
      data_source_id: dataSourceId,
      filter: { property: "Status", select: { equals: "In Progress" } },
      sorts: [{ property: "Due Date", direction: "ascending" }],
      start_cursor: cursor,
      page_size: 100, // max allowed; fewer round-trips
    });

    rows.push(...(response.results as PageObjectResponse[]));
    cursor = response.next_cursor ?? undefined; // null → stop
  } while (cursor !== undefined);

  return rows;
}

const allTasks = await queryAllRows(process.env.NOTION_DATA_SOURCE_ID!);
console.log(`Fetched ${allTasks.length} tasks across all pages`);

Helpers — let the SDK drive the cursor

The official client ships two pagination helpers so you never touch next_cursor by hand:

import { Client, iteratePaginatedAPI, collectPaginatedAPI } from "@notionhq/client";

const notion = new Client({ auth: process.env.NOTION_TOKEN });
const data_source_id = process.env.NOTION_DATA_SOURCE_ID!;

// Stream one row at a time — memory-efficient for huge data sources
for await (const row of iteratePaginatedAPI(notion.dataSources.query, { data_source_id })) {
  console.log(row.id);
}

// Collect everything into one array — only when the dataset fits in memory
const allRows = await collectPaginatedAPI(notion.dataSources.query, { data_source_id });
console.log(`Fetched ${allRows.length} rows`);

Gotcha — page_size and rate limits. page_size is capped at 100; asking for more is ignored, so a large database always needs multiple round-trips. Notion throttles at roughly 3 requests/second, so a deep paginated pull can trip a 429. Keep page_size: 100 to minimize calls, and add a small delay between pages (await new Promise(r => setTimeout(r, 350))) or honour the Retry-After header on 429s.


Environment Variables

# Required — internal integration token (prefix ntn_… ; older tokens were secret_…)
NOTION_TOKEN=ntn_...                # From notion.so/profile/integrations

# Optional — IDs for commonly used databases/data sources/pages
NOTION_TASKS_DB_ID=...             # Task tracker database ID (container)
NOTION_DATA_SOURCE_ID=...          # Data source ID inside that DB (query/create target)
NOTION_DOCS_PAGE_ID=...            # Your docs root page ID
NOTION_SPRINT_DB_ID=...            # Sprint planning database

Find database/page IDs from the URL: notion.so/[workspace]/[page-id]. Resolve a data_source_id at runtime via databases.retrieve({ database_id }).data_sources[0].id.


Automation Workflows

Claude Code Hook: Auto-document on Feature Completion

.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/notion-update-docs.js"
          }
        ]
      }
    ]
  }
}

scripts/notion-update-docs.js:

import { Client } from "@notionhq/client";
import { execFileSync } from "child_process";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

// Get last commit message
const commitMsg = execFileSync("git", ["log", "-1", "--pretty=%B"]).toString().trim();
if (!commitMsg.startsWith("feat:")) process.exit(0);

// Append to changelog page
await notion.blocks.children.append({
  block_id: process.env.NOTION_CHANGELOG_PAGE_ID,
  children: [{
    object: "block",
    type: "bulleted_list_item",
    bulleted_list_item: {
      rich_text: [{
        text: {
          content: `[${new Date().toISOString().slice(0, 10)}] ${commitMsg}`,
        },
      }],
    },
  }],
});

console.log("Changelog updated in Notion");

Slash Command: Create Task in Notion

.claude/commands/notion-task.md:

Create a new task in the Notion task database for: $ARGUMENTS

Use the Notion MCP to create a page (row) in the tasks data source with:
- Name: $ARGUMENTS
- Status: Todo
- Priority: Medium
- Assignee: (leave blank)
- Source: "Claude Code"

Report the URL of the created Notion page.

Usage: /project:notion-task "Refactor the authentication module"

CI/CD: Auto-update Notion Sprint Board

# .github/workflows/notion-update.yml
name: Update Notion on Deploy
on:
  workflow_run:
    workflows: ["Deploy to Production"]
    types: [completed]

jobs:
  update-notion:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm install @notionhq/client   # v5+ (data-source model)
      - name: Update Notion
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_SPRINT_DB_ID: ${{ secrets.NOTION_SPRINT_DB_ID }}
        run: |
          node -e "
          const { Client } = require('@notionhq/client');
          const notion = new Client({ auth: process.env.NOTION_TOKEN });
          (async () => {
            const db = await notion.databases.retrieve({ database_id: process.env.NOTION_SPRINT_DB_ID });
            await notion.pages.create({
              parent: { type: 'data_source_id', data_source_id: db.data_sources[0].id },
              properties: {
                Name: { title: [{ text: { content: 'Deployed: ${{ github.sha }}' } }] },
                Status: { select: { name: 'Released' } },
                Date: { date: { start: new Date().toISOString().slice(0,10) } }
              }
            });
            console.log('Notion updated');
          })();
          "

Common Use Cases

Use Case Approach
Task tracking MCP create-a-page in the task data source
Sprint planning MCP query-data-source + analysis
Auto-changelog Hook on git commit → append block children
Knowledge base search MCP search for internal docs
Meeting notes Create a page with structured template
Release notes PR merge → auto-create Notion page

Troubleshooting

Issue Fix
401 Unauthorized Check NOTION_TOKEN value (prefix ntn_…); ensure integration is valid
Page not found (404) Share the page/database with your integration in Notion UI
databases.query is not a function You're on SDK v5 — use dataSources.query({ data_source_id }); resolve the id via databases.retrieve
body failed validation: parent.database_id Under 2025-09-03+, page parent is { type: "data_source_id", data_source_id }
Properties missing Data source schema must match property names exactly (case-sensitive)
Blocks not rendering Rich text must be an array; use [{ text: { content: "..." } }]
Rate limit (429) Notion allows ~3 req/sec; add await new Promise(r => setTimeout(r, 350)) between calls

Setup tip: For the integration to access a database, open the database in Notion → ... menu → Connections → add your integration (or manage it under the integration's Access tab).

Official docs:

OpenAI Integration Guide

Technology: open-ai · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/open-ai

Insight:

OpenAI is codeAmani's secondary provider — reach for it when structured output or function calling matters (its JSON-schema mode is strong), while Claude stays primary for reasoning. Build new work on the Responses API (client.responses), now OpenAI's recommended default; Chat Completions still works ("the previous standard, supported indefinitely") and is the shape the Vercel AI SDK swaps under generateObject, so moving between providers stays a one-line model change.

 ██████╗ ██████╗ ███████╗███╗   ██╗ █████╗ ██╗
██╔═══██╗██╔══██╗██╔════╝████╗  ██║██╔══██╗██║
██║   ██║██████╔╝█████╗  ██╔██╗ ██║███████║██║
██║   ██║██╔═══╝ ██╔══╝  ██║╚██╗██║██╔══██║██║
╚██████╔╝██║     ███████╗██║ ╚████║██║  ██║██║
 ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═══╝╚═╝  ╚═╝╚═╝

OpenAI Integration Guide

Focus: Integrating OpenAI models and APIs alongside Claude Code workflows — dual-provider pipelines, the Responses API's built-in MCP tool, and cross-model automation.

Overview

Claude Code and OpenAI are complementary. OpenAI's models — the GPT-5.6 family (gpt-5.6, with the sol / terra / luna variants) plus the older GPT-5, GPT-4.1, GPT-4o and o-series models — are consumed inside Claude Code via the openai SDK. New work should target the Responses API (client.responses), which OpenAI now recommends as the default surface for all new projects; Chat Completions (client.chat.completions) remains fully supported. This lets you build hybrid workflows — e.g., route code and reasoning tasks to Claude, structured-extraction and vision tasks to GPT-5.6 — all from one Claude Code session.

Here is the simple mental model of where OpenAI sits as the secondary provider — reach for it when structured output or function calling matters, while Claude stays primary for reasoning.

flowchart TD
  A["Task in Claude Code session"] --> Q1{"Structured output<br/>or function calling?"}
  Q1 -->|"yes"| B["Route to OpenAI<br/>Responses API - gpt-5.6"]
  Q1 -->|"no - reasoning, code gen"| C["Stay on Claude<br/>primary"]
  B --> D["openai SDK - client.responses"]
  D --> E["Result back in session"]
  C --> E

Official Documentation

Resource URL
OpenAI API Reference https://developers.openai.com/api/reference/overview
Models overview https://developers.openai.com/api/docs/models
Responses vs. Chat Completions https://developers.openai.com/api/docs/guides/migrate-to-responses
Remote MCP tool (Responses API) https://developers.openai.com/api/docs/guides/tools-connectors-mcp
OpenAI Node SDK https://github.com/openai/openai-node
OpenAI Python SDK https://github.com/openai/openai-python

Docs domain note: platform.openai.com/docs/* links still work but now 301-redirect to developers.openai.com. Prefer the developers.openai.com canonical URLs above.


Models at a glance (verify at the models page)

Tier Model ids Use it for
Frontier gpt-5.6-sol (alias gpt-5.6) Hardest reasoning/coding — but Claude is codeAmani's primary here
Balanced gpt-5.6-terra General structured output at lower cost
Budget gpt-5.6-luna Cheap-fast extraction, routing, classification
Dedicated reasoning o3, o4-mini Chain-of-thought tasks (GPT-5 also reasons via effort levels)
Embeddings text-embedding-3-small (1536), text-embedding-3-large (3072) RAG / semantic search

GPT-5.6 models take a reasoning: { effort: "low" | "medium" | "high" | ... } control instead of a separate reasoning model. Pin a dated snapshot (e.g. gpt-5.6-2026-…) for reproducible CI; use the floating alias for product code.


MCP integration — the Responses API mcp tool

OpenAI adopted the Model Context Protocol; the integration point is a built-in tool on the Responses API. You give a GPT-5 model the URL of a remote MCP server and it calls that server's tools itself — no separate account-bridge package needed.

import OpenAI from "openai";

const client = new OpenAI();

const resp = await client.responses.create({
  model: "gpt-5.6",
  tools: [
    {
      type: "mcp",
      server_label: "dmcp",
      server_description: "A dice-rolling MCP server.",
      server_url: "https://dmcp-server.deno.dev/mcp",
      require_approval: "never", // approve tool calls in production instead
    },
  ],
  input: "Roll 2d4+1",
});

console.log(resp.output_text);

Direction of travel: this makes the OpenAI model an MCP client. To go the other way — expose your own resources to Claude Code as MCP tools — build a standard MCP server (see the repo's mcp-server/ guide), not an OpenAI-specific bridge. Never hand a third-party remote MCP server your OPENAI_API_KEY; the key stays server-side and require_approval gates tool execution.

Full guide: https://developers.openai.com/api/docs/guides/tools-connectors-mcp


Calling OpenAI from a Claude Code session

The openai Python package's legacy openai api … CLI has been removed from the docs; the SDK is the supported interface. For a quick one-off from a Claude Code shell, hit the Responses endpoint directly:

# Second opinion from GPT-5.6 without leaving the session
curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.6","input":"Review this code for memory leaks: ..."}'

The reply text is at .output_text in the JSON response.


OpenAI SDK Integration

You are about to wire up the core request flow — here is how a Responses call travels from your app through the SDK to the model and back.

sequenceDiagram
  participant App as "Your app"
  participant SDK as "openai SDK"
  participant API as "OpenAI Responses API"
  App->>SDK: "client.responses.create - gpt-5.6"
  SDK->>API: "send input with OPENAI_API_KEY"
  API->>API: "run model on input"
  API-->>SDK: "response with output items"
  SDK-->>App: "response.output_text"

Node.js / TypeScript

npm install openai   # v7.x — requires Node >= 22, zod ^3.25 || ^4
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// Responses API (recommended default)
const response = await client.responses.create({
  model: "gpt-5.6",
  instructions: "You are a TypeScript expert.",
  input: "Convert this class to use composition over inheritance.",
});

console.log(response.output_text);

Chat Completions is still supported if you need that shape (e.g. cross-provider code via the Vercel AI SDK):

const completion = await client.chat.completions.create({
  model: "gpt-5.6",
  messages: [
    { role: "system", content: "You are a TypeScript expert." },
    { role: "user", content: "Convert this class to use composition over inheritance." },
  ],
});
console.log(completion.choices[0].message.content);

Python

pip install openai   # v3.x — requires Python >= 3.10
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

response = client.responses.create(
    model="gpt-5.6",
    input="Generate unit tests for this function.",
)
print(response.output_text)

Structured output & function calling

This is the reason OpenAI earns its place as the secondary provider. When you need the model to return data your code can trust — not prose you have to regex — reach for Structured Outputs. With a strict JSON schema the model is guaranteed to emit JSON that matches, and the SDK hands you a fully typed object — no JSON.parse, no validation boilerplate. Structured Outputs work on GPT-4o (2024-08-06+) and every GPT-5 model, so with gpt-5.6 the old snapshot caveat is a non-issue.

Here is the flow: you define a Zod schema, the SDK ships it as a strict JSON schema, the model is constrained to match, and you get a typed object back.

flowchart TD
  A["Zod schema in your app"] --> B["zodTextFormat<br/>(Responses API)"]
  B --> C["Strict JSON schema<br/>sent to model"]
  C --> D["Model constrained<br/>to schema"]
  D --> E["response.output_parsed<br/>typed object"]

Use client.responses.parse() with zodTextFormat() from openai/helpers/zod. The parsed object arrives on response.output_parsed, typed as z.infer<typeof Schema>.

import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// e.g. extract structured order data from a free-text M-Pesa SMS
const OrderExtraction = z.object({
  amount_kes: z.number().int(),       // Daraja amounts are integer KES
  phone: z.string(),                   // normalise to 254XXXXXXXXX downstream
  reference: z.string(),
  confidence: z.enum(["high", "medium", "low"]),
});

const response = await client.responses.parse({
  model: "gpt-5.6",
  input: [
    { role: "system", content: "Extract the payment fields from the message." },
    { role: "user", content: "Got KES 1500 from 0712345678 ref INV-204" },
  ],
  text: { format: zodTextFormat(OrderExtraction, "order_extraction") },
});

const order = response.output_parsed;
if (order) {
  console.log(order.amount_kes, order.phone, order.confidence); // fully typed
}

(b) Tool / function call — Responses API

Declare function tools with the flat Responses shape (type: "function" at the top level, strict: true). The model returns function_call items in response.output; each carries name, a call_id, and arguments as a JSON string.

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const response = await client.responses.create({
  model: "gpt-5.6",
  input: "Charge 0712345678 KES 500 for order INV-9",
  tools: [
    {
      type: "function",
      name: "initiate_stk_push",
      description: "Start an M-Pesa STK push.",
      strict: true,
      parameters: {
        type: "object",
        additionalProperties: false,
        required: ["amount_kes", "phone", "account_ref"],
        properties: {
          amount_kes: { type: "integer" },
          phone: { type: "string" },
          account_ref: { type: "string" },
        },
      },
    },
  ],
});

for (const item of response.output) {
  if (item.type === "function_call" && item.name === "initiate_stk_push") {
    const args = JSON.parse(item.arguments) as {
      amount_kes: number; phone: string; account_ref: string;
    };
    // now call your real lib/mpesa-stk.ts with typed, schema-validated args
    console.log(args.amount_kes, args.phone, args.account_ref);
  }
}

(c) Chat Completions equivalent (still supported)

If you're on the Chat Completions shape, the helpers are zodResponseFormat() (whole-response schema) and zodFunction() (tool arguments) via client.chat.completions.parse(); results land on choices[0].message.parsed and tool_calls[].function.parsed_arguments. See examples/structured-output.ts.

Gotcha: every field in a strict schema is required by default. To make a field optional, model it as z.union([T, z.null()]) (nullable) rather than .optional() — strict mode does not allow omitted keys — and keep additionalProperties: false.


Environment Variables

# Required
OPENAI_API_KEY=sk-proj-...

# Optional
OPENAI_ORG_ID=org-...
OPENAI_PROJECT_ID=proj_...
OPENAI_BASE_URL=https://api.openai.com/v1    # default; change for Azure OpenAI

Store in .env and load with dotenv or python-dotenv. The key is server-side only — never ship it to the browser.


Automation Workflows

Dual-Provider Review Hook

Use a Claude Code Stop hook to send the session summary to OpenAI for a cross-model review:

.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/openai-second-opinion.js"
          }
        ]
      }
    ]
  }
}

scripts/openai-second-opinion.js:

import OpenAI from "openai";
import { readFileSync } from "fs";

const client = new OpenAI();
const sessionLog = readFileSync(".claude/session.log", "utf-8");

const result = await client.responses.create({
  model: "gpt-5.6",
  instructions: "Review this Claude Code session for potential issues.",
  input: sessionLog,
});

console.log("GPT-5.6 review:", result.output_text);

Slash Command: Route to GPT-5.6

.claude/commands/gpt.md:

Use the Bash tool to call OpenAI's Responses API with this prompt: $ARGUMENTS

Command:
```bash
curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.6","input":"'"$ARGUMENTS"'"}'

Usage: `/project:gpt "What are the tradeoffs of this architecture?"`

### CI/CD: OpenAI Code Quality Gate

```yaml
# .github/workflows/ai-review.yml
name: AI Code Review
on: [pull_request]

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Get diff
        run: git diff origin/main...HEAD > diff.txt
      - name: OpenAI review
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          pip install openai
          python scripts/review.py diff.txt

Common Use Cases

Use Case Approach
Vision / image understanding Pass image inputs to gpt-5.6 via the Responses API (input_image)
Embeddings for code search text-embedding-3-small on your codebase
Fine-tuning for style Fine-tune a small model (e.g. gpt-5.6-luna) on your code patterns
Structured extraction responses.parse + zodTextFormat on gpt-5.6-terra / luna
Cross-model validation Claude drafts, GPT-5.6 validates

Troubleshooting

Issue Fix
401 Unauthorized Check OPENAI_API_KEY value and project permissions
Model not available Check model access at developers.openai.com/api/docs/models and your project tier
output_parsed is null The model refused or the schema was invalid — inspect response.output for a refusal item
Strict-schema error Every property must be in required; use z.null() unions for optional fields, additionalProperties: false
Rate limits Use exponential backoff; check tier limits at platform.openai.com/settings/limits
Azure OpenAI endpoint Set OPENAI_BASE_URL=https://<resource>.openai.azure.com

Official docs:

OpenClaw Integration Guide

Technology: openclaw · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/openclaw

Insight:

OpenClaw is a self-hosted agent gateway, not an SDK — one long-lived daemon on your own box that fronts 30+ messaging channels, 50+ model providers, and a file-based skill system, all driven by the openclaw CLI. The trade-off is ownership for ops: you get a WhatsApp/Telegram-native agent with no per-seat SaaS bill, but you run the daemon, hold the keys, and inherit the blast radius of an agent that can exec. For codeAmani it is the fastest way to put a Claude-backed brain behind a WhatsApp number — ideal for internal ops and prototyping the Kenya builds, with the Meta Cloud API still the answer for customer-facing production traffic.

 ██████╗ ██████╗ ███████╗███╗   ██╗ ██████╗██╗      █████╗ ██╗    ██╗
██╔═══██╗██╔══██╗██╔════╝████╗  ██║██╔════╝██║     ██╔══██╗██║    ██║
██║   ██║██████╔╝█████╗  ██╔██╗ ██║██║     ██║     ███████║██║ █╗ ██║
██║   ██║██╔═══╝ ██╔══╝  ██║╚██╗██║██║     ██║     ██╔══██║██║███╗██║
╚██████╔╝██║     ███████╗██║ ╚████║╚██████╗███████╗██║  ██║╚███╔███╔╝
 ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═══╝ ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝

OpenClaw Integration Guide

Focus: Running a self-hosted OpenClaw Gateway as a personal/ops AI agent that lives inside WhatsApp, Telegram, Slack and friends — install, config, agents, skills, MCP, and how to harden it before it leaves loopback.

Overview

OpenClaw (MIT, github.com/openclaw/openclaw, developed in the open by the non-profit OpenClaw Foundation — openclaw.org) is "a personal AI assistant that runs on your devices and meets you in the channels you already use." It is not a library you import — there is no import { OpenClaw }. It is a daemon plus a CLI: you install the openclaw npm package globally, run an onboarding wizard, and end up with a long-lived Gateway process on 127.0.0.1:18789 that owns your messaging connections, your model providers, your agent sessions, and your tool policy.

The design point that makes it different from an agent framework: the messaging platform is the UI. There is no app to build. You DM a WhatsApp or Telegram number, the Gateway routes that message to an agent, the agent runs tools in a workspace directory on your machine, and the reply comes back down the same channel. A browser Control UI (openclaw dashboard) exists for administration, not as the primary surface.

OpenClaw Agent SDK / framework Managed bot platform
Shape Daemon + CLI you self-host Library you compile into an app Hosted SaaS
UI Existing messaging apps You build it Vendor's console
Model Bring your own key, 50+ providers Whatever you wire Vendor's models
Secrets Your disk (~/.openclaw/) Your env Vendor holds them
Ops burden Yours (process, updates, auth) Yours (deploy) None
Multi-tenant No — single-operator by design Yes, if you build it Yes
flowchart LR
  subgraph CH["Channels"]
    W["WhatsApp"]
    T["Telegram"]
    S["Slack / Discord / Signal"]
  end
  subgraph HOST["Your host — one Gateway daemon"]
    G["Gateway<br/>127.0.0.1:18789<br/>WebSocket + HTTP"]
    A["Agents<br/>agents.entries.*<br/>workspace + skills"]
    TP["Tool policy<br/>tools.allow / tools.deny"]
  end
  subgraph CP["Control plane"]
    C["openclaw CLI"]
    U["Control UI<br/>openclaw dashboard"]
    N["Nodes<br/>macOS / iOS / Android"]
  end
  P["Model providers<br/>anthropic/ · openai/ · ollama/"]
  M["MCP servers<br/>stdio · HTTP · SSE"]

  W --> G
  T --> G
  S --> G
  C --> G
  U --> G
  N --> G
  G --> A
  A --> TP
  TP --> M
  A --> P

Official Documentation

Resource URL
Getting started https://docs.openclaw.ai/start/getting-started
Install (all methods) https://docs.openclaw.ai/install
Architecture (Gateway, nodes, frames) https://docs.openclaw.ai/concepts/architecture
Gateway config & security https://docs.openclaw.ai/gateway
Agent config (agents.*, bindings) https://docs.openclaw.ai/gateway/config-agents
Channels index https://docs.openclaw.ai/channels
WhatsApp channel https://docs.openclaw.ai/channels/whatsapp
Model providers (50+) https://docs.openclaw.ai/providers
Anthropic provider https://docs.openclaw.ai/providers/anthropic
Skills (SKILL.md) https://docs.openclaw.ai/tools/skills
Tools & tool policy https://docs.openclaw.ai/tools
MCP servers https://docs.openclaw.ai/tools/mcp
CLI reference https://docs.openclaw.ai/cli
ClawHub (plugin/skill registry) https://clawhub.ai
Repository https://github.com/openclaw/openclaw

Install

Requirements: Node 22.22.3+, 24.15+, or 25.9+ (Node 26 recommended), plus an API key from at least one model provider.

The one-liner installers handle Node and the daemon for you:

# macOS / Linux / WSL2
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash
# Windows (PowerShell)
iwr -useb https://openclaw.ai/install.ps1 | iex

If you manage Node yourself, install the npm package globally. npm 12+ blocks unapproved lifecycle scripts, so the --allow-scripts flag is required (this is the exact form the docs ship):

npm install -g openclaw@latest --allow-scripts openclaw
openclaw onboard --install-daemon
# pnpm
pnpm add -g --allow-build=openclaw openclaw@latest && openclaw onboard --install-daemon

# bun
bun add -g --trust openclaw@latest && openclaw onboard --install-daemon

Verify:

openclaw --version
openclaw doctor           # config + environment diagnostics
openclaw gateway status   # should report listening on 18789
openclaw dashboard        # opens the Control UI in a browser

Releases are date-versioned (2026.7.1-2 style) and ship on four dist-tags: latest, extended-stable, beta, alpha. Pin extended-stable on anything you don't want moving under you.


Configuration

State lives in $HOME/.openclaw/; the config file is JSON5 (comments and trailing commas allowed) and is normally ~/.openclaw/openclaw.json. Override the path with OPENCLAW_CONFIG_PATH (keep it a real file — OpenClaw rewrites config atomically, so a symlinked openclaw.json gets its target replaced). You can edit it directly: the Gateway watches the file and hot-reloads changes, and it refuses to start on a config that fails validation. Still, prefer the CLI so a typo is caught up front rather than at reload:

openclaw config file            # print the resolved config path
openclaw config get agents.defaults.model
openclaw config set agents.defaults.model.primary "anthropic/claude-opus-5"
openclaw config validate
openclaw config schema          # full JSON Schema

Model providers

Models are addressed as "provider/model". Provider credentials live under env.vars in the config (or in the process environment):

{
  env: {
    vars: {
      ANTHROPIC_API_KEY: "sk-ant-...", // resolved from your secret manager, not committed
    },
  },
  agents: {
    defaults: {
      model: {
        primary: "anthropic/claude-opus-5",
        fallbacks: ["anthropic/claude-sonnet-5"],
      },
      utilityModel: "anthropic/claude-fable-5", // cheap model for routing/summarisation
      thinkingDefault: "low",
    },
  },
}
openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
openclaw models list --provider anthropic

The "provider/model" shape and the Claude ids used here are verified against docs/providers/anthropic.md in the tree, which documents anthropic/claude-opus-5, anthropic/claude-sonnet-5, anthropic/claude-fable-5, anthropic/claude-mythos-5, and dated builds such as anthropic/claude-opus-4-8. (Anthropic can also be driven through an existing Claude Code CLI login on the same host instead of an API key; for a long-lived Gateway, prefer a dedicated ANTHROPIC_API_KEY.)

OpenClaw's provider directory covers 50+ backends — Anthropic, OpenAI, Google, Mistral, Cohere, Bedrock, Groq, Together AI, DeepSeek, Perplexity, plus local runtimes (Ollama, LM Studio, vLLM, llama.cpp, SGLang). Per codeAmani's AI routing policy, keep primary on Anthropic Claude and reserve cheaper tiers for utilityModel.

Agents

Agents are config objects, not code. agents.defaults sets the inherited baseline; agents.entries.<id> overrides per agent; bindings route channels to agents.

{
  agents: {
    ownership: "explicit", // required once you run more than one agent
    defaults: {
      workspace: "~/.openclaw/workspace",
      model: { primary: "anthropic/claude-opus-5" },
      skills: ["duka-inventory"],
      heartbeat: { every: "30m" },
    },
    entries: {
      ops: {
        name: "Ops Agent",
        workspace: "~/.openclaw/workspace-ops",
        model: "anthropic/claude-opus-5",
        identity: { name: "Amani", emoji: "🕊️" },
      },
      support: {
        workspace: "~/.openclaw/workspace-support",
        model: { primary: "anthropic/claude-sonnet-5", fallbacks: [] },
        skills: ["docs-search"],
      },
    },
  },
  bindings: [
    { agentId: "ops", match: { channel: "whatsapp", accountId: "internal" } },
    { agentId: "support", match: { channel: "telegram" } },
  ],
}

Binding resolution is deterministic and narrows outward: peer → guild/team → account → channel-wide fallback. With ownership: "explicit" there is no default agent — an unbound channel simply has nowhere to route.

openclaw agents list
openclaw agents add
openclaw agents bind          # attach an agent to a channel/account

Channels

Telegram, Reef, and WebChat are bundled. Everything else — WhatsApp, Slack, Discord, Signal, iMessage, Matrix, Microsoft Teams, Google Chat, LINE, SMS, IRC, Twitch and more — is an official plugin installed on demand.

openclaw plugins install clawhub:@openclaw/whatsapp
openclaw channels add --channel whatsapp     # interactive; installs the plugin if missing
openclaw channels login --channel whatsapp   # QR pairing
openclaw channels list

WhatsApp

Set the access policy before you log in, or the first stranger to message the number becomes a conversation:

{
  channels: {
    whatsapp: {
      dmPolicy: "pairing",              // unknown senders need owner approval
      allowFrom: ["+254712345678"],
      groupPolicy: "allowlist",
      groupAllowFrom: ["+254712345678"],
      textChunkLimit: 4000,             // default; lower it for 2G/3G users
      sendReadReceipts: true,
      replyToMode: "first",             // off | first | all | batched
    },
  },
}
dmPolicy Effect
pairing Unknown senders raise an approval request (expires after 1h, max 3 pending)
allowlist Only numbers in allowFrom get through
open Everyone — requires an explicit allowFrom: ["*"]
disabled No DMs at all

Approve a pending pairing from the Control UI (Settings → Channels → DM access requests) or the CLI:

openclaw pairing approve whatsapp <CODE>

Session credentials land in ~/.openclaw/credentials/whatsapp/<accountId>/creds.json — treat that directory as a secret. Multi-account setups live under channels.whatsapp.accounts.<id>, which is how one Gateway serves a personal and a business number with different agents bound to each.

Read this before shipping (settled against the source): the WhatsApp channel links a WhatsApp account via QR pairing over WhatsApp Web, not the Meta WhatsApp Business Cloud API and not Twilio. This is not inference — extensions/whatsapp depends on baileys (the WhatsApp-Web multi-device library), its login path is QR-only (login-qr-*, onQr), and the docs state it plainly: "production-ready via WhatsApp Web (Baileys). The gateway owns the linked session(s); there is no separate Twilio WhatsApp channel." Perfect for an internal ops number or a prototype; the wrong tool for customer-facing volume on a business number — see codeAmani notes.


Skills

Skills are the extension unit for agent behaviour, and they are just files: a directory containing a SKILL.md with YAML frontmatter plus a markdown body, following the AgentSkills spec.

---
name: mpesa-reconcile
description: Reconcile M-Pesa C2B callbacks against open orders and flag mismatches.
---

# M-Pesa reconciliation

When asked to reconcile payments:
1. Read `orders.csv` from the workspace.
2. Match on `CheckoutRequestID`, never on amount alone.
3. Report unmatched rows as a table; never auto-refund.

Discovery walks up to 6 levels deep inside configured roots, and the name in frontmatter wins over the directory name. When the same name appears twice, the highest-priority source wins:

Priority Source
1 Workspace skills — <workspace>/skills
2 Project agent skills — <workspace>/.agents/skills
3 Personal agent skills — ~/.agents/skills
4 Managed/local skills — <state-dir>/skills
5 Bundled skills (shipped with OpenClaw)
6 Extra directories + plugin skills
openclaw skills install @owner/slug              # from ClawHub
openclaw skills install git:owner/repo@ref       # from a git ref
openclaw skills install ./path/to/skill --as mpesa-reconcile
openclaw skills install @owner/slug --global     # visible to every agent
openclaw skills verify @owner/slug               # trust check before install
openclaw skills update --all

At run time OpenClaw resolves eligible skills against gating rules and allowlists, injects skills.entries.<name>.env variables, and compiles a snapshot into the system prompt as XML. Skills surface as slash commands (/mpesa-reconcile) and as $mpesa-reconcile references inside a prompt; set disable-model-invocation: true in frontmatter to make a skill user-only.


Tools and tool policy

Agents get a broad built-in toolset — exec, process, terminal, code_execution; read/write/edit/apply_patch; web_search, x_search, web_fetch, browser; view_image, image_generate, tts; sessions_*, subagents, agents_wait, goal; cron and heartbeat_respond for background work; ask_user, message, screen.

Policy is enforced before the model call — a denied tool's schema is never sent for that turn, so the model cannot even attempt it:

{
  agents: {
    entries: {
      support: {
        tools: {
          deny: ["exec", "terminal", "process", "code_execution"],
        },
      },
    },
  },
}

That deny-list is the single most important config line for any agent reachable from a public channel.

MCP servers

MCP servers plug in as first-class tool sources under mcp.servers, over stdio, streamable HTTP, or SSE:

openclaw mcp add
openclaw mcp login <name>            # OAuth for protected servers
openclaw mcp doctor <name> --probe   # reachability + capability probe
openclaw mcp status --verbose

Their tools flow through the same tool-profile and policy controls, and can be narrowed with toolFilter.include / toolFilter.exclude.


Gateway operations & security

One Gateway per host, one multiplexed port for WebSocket control, HTTP APIs, and the Control UI (the node/device bridge listens separately on 18790, and channel plugins that need an inbound webhook — e.g. MS Teams on 3978 — open their own).

openclaw gateway start
openclaw gateway status
openclaw gateway restart
openclaw logs
openclaw health

Port resolves --port → OPENCLAW_GATEWAY_PORT → gateway.port → 18789. Bind resolves CLI override → gateway.bind → loopback (containers default to auto, i.e. 0.0.0.0, unless Tailscale serve/funnel is active, which forces loopback).

Authentication is mandatory by default, and OpenClaw refuses to bind a non-loopback interface without it:

{
  gateway: {
    bind: "loopback",
    auth: { token: "..." },            // or auth.password
    controlUi: {
      allowedOrigins: ["https://claw.internal.example"], // required for remote browsers
    },
  },
}

Equivalent env vars: OPENCLAW_GATEWAY_TOKEN, OPENCLAW_GATEWAY_PASSWORD. For a reverse proxy that terminates auth itself, set gateway.auth.mode: "trusted-proxy".

For remote access the docs push Tailscale/VPN first, SSH tunnel second — and an SSH tunnel does not bypass gateway auth; clients still send the token:

ssh -N -L 18789:127.0.0.1:18789 user@gateway-host

Control-plane clients (CLI, Control UI, macOS app, automation) and nodes (macOS/iOS/Android/headless devices exposing camera.*, screen.record, location.get) all speak the same typed WebSocket protocol: a mandatory connect handshake, then {type:"req", id, method, params} → {type:"res", id, ok, payload|error} with events pushed as {type:"event", event, payload, seq?, stateVersion?}. Side-effecting methods (send, agent) accept idempotency keys — use them, because a reconnect that replays a send is a duplicate WhatsApp message to a customer.


Environment Variables

# Model provider (codeAmani primary — see AI routing policy)
ANTHROPIC_API_KEY=sk-ant-...

# Gateway auth — required for any non-loopback bind
OPENCLAW_GATEWAY_TOKEN=...
# or
OPENCLAW_GATEWAY_PASSWORD=...

# Optional
OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_CONFIG_PATH=/etc/openclaw/openclaw.json
OPENCLAW_SERVICE_REPAIR_POLICY=...   # hand lifecycle to an external supervisor

Repository layout (grounding)

If you clone openclaw/openclaw to read the source, the monorepo is pnpm-workspace'd (packages: [., ui, packages/*, extensions/*, examples/*]) and the names in the docs don't map 1:1 to folders:

In the tree Is Note
extensions/ (~157 pkgs) Channels + model providers + tools What the docs and CLI call "plugins." Per the repo's own AGENTS.md: "Product/docs/UI/changelog wording: 'plugin/plugins'; extensions/ is internal." WhatsApp is extensions/whatsapp (pkg @openclaw/whatsapp), Anthropic is extensions/anthropic.
apps/ Companion clients android, ios, macos, linux, shared — the "nodes" that expose camera.*/screen.*/location.*.
config/ Repo tooling config lint/tsconfig/budgets — not runtime Gateway config (that lives at ~/.openclaw/openclaw.json).
deploy/ Deploy fragments just fly.private.toml; root also ships Dockerfile, docker-compose.yml, fly.toml, render.yaml.
docs/ The published docs Same paths as docs.openclaw.ai (e.g. docs/channels/whatsapp.md → /channels/whatsapp).
AGENTS.md Contributor policy CLAUDE.md is a symlink to it — edit AGENTS.md only.

The shipped docker-compose.yml starts the Gateway with --bind ${OPENCLAW_GATEWAY_BIND:-lan} and publishes 18789 (Gateway), 18790 (node/device bridge) and 3978 (MS Teams); it pins OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH/OPENCLAW_WORKSPACE_DIR under /home/node/.openclaw and expects OPENCLAW_GATEWAY_TOKEN — a containerised deploy is non-loopback by construction, so the token is mandatory.


codeAmani notes


Troubleshooting

Issue Fix
npm install -g openclaw skips setup / CLI missing npm 12+ blocks lifecycle scripts — reinstall with --allow-scripts openclaw (pnpm: --allow-build=openclaw, bun: --trust)
Refuses to start: "refusing to bind gateway … without auth" Non-loopback bind requires gateway.auth.token/.password or OPENCLAW_GATEWAY_TOKEN
Control UI blocked in a remote browser Add the origin to gateway.controlUi.allowedOrigins
Tunnel works but requests are rejected SSH tunnels do not bypass gateway auth — the client must still send the token
WhatsApp QR expires on a headless box Get the QR to your phone fast, or run openclaw channels login from a machine with a display and copy the credentials dir
Bot ignores an incoming DM dmPolicy: "pairing" is waiting on approval — openclaw pairing approve whatsapp <CODE> (expires after 1h, max 3 pending)
Messages arrive but no agent replies With agents.ownership: "explicit" there is no default agent — add a bindings entry for that channel/account
Skill installed but never used Check the name in SKILL.md frontmatter (it overrides the directory), the source precedence table, and whether disable-model-invocation: true is set
MCP tools missing from the agent openclaw mcp doctor <name> --probe, then check toolFilter.include/exclude and tools.allow/deny
Duplicate outbound messages after a reconnect Send send/agent requests with idempotency keys
Node version errors on install Needs Node 22.22.3+, 24.15+, or 25.9+ (26 recommended)

Official docs:

Openship Integration Guide

Technology: openship · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/openship

Insight:

Openship is the self-hosted deploy tier: Apache-2.0 CI/CD that points at a repo and builds, ships, routes, and TLS-terminates it on hardware you own — a Vercel-shaped workflow without the per-seat bill or the lock-in. The trade you accept is that you are now the platform team: the OpenResty edge, Let's Encrypt renewals, Postgres, and backups are yours to keep alive. codeAmani runs a fork (codeAmani-Solutions/open-ship) as its local fleet control plane, with secrets injected by Hazina and agents wired in over MCP.

 ██████╗ ██████╗ ███████╗███╗   ██╗███████╗██╗  ██╗██╗██████╗
██╔═══██╗██╔══██╗██╔════╝████╗  ██║██╔════╝██║  ██║██║██╔══██╗
██║   ██║██████╔╝█████╗  ██╔██╗ ██║███████╗███████║██║██████╔╝
██║   ██║██╔═══╝ ██╔══╝  ██║╚██╗██║╚════██║██╔══██║██║██╔═══╝
╚██████╔╝██║     ███████╗██║ ╚████║███████║██║  ██║██║██║
 ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═══╝╚══════╝╚═╝  ╚═╝╚═╝╚═╝

Openship Integration Guide

Focus: Running codeAmani's own deploy platform — install the control plane, deploy a repo end to end, and operate the local fork (codeAmani-Solutions/open-ship) that fronts the fleet.

Overview

Openship is an open-source, self-hostable deployment platform with built-in CI/CD, licensed Apache-2.0. Point it at a GitHub repo, a folder on disk, or a prebuilt artifact and it runs one pipeline end to end: detect the stack, build an image, run it on loopback, then write an OpenResty reverse-proxy vhost and issue a Let's Encrypt certificate for your domain. Push-to-deploy, preview environments, rollbacks, managed Postgres/MySQL/MongoDB/Redis, a built-in SMTP engine, and backups all live in the same control plane.

Three interfaces drive the same backend: a desktop app (Electron), a web dashboard (Next.js), and a CLI (npm i -g openship) — plus a REST API and an MCP endpoint so agents can drive it.

⚠️ Name collision. There is an unrelated project openshiporg/openship about e-commerce order fulfilment. This guide is oblien/openship — the deployment platform at https://openship.io. The npm package openship is the deployment CLI.

Where it sits next to the managed hosts

Vercel Netlify Render Cloudflare Openship
Model Managed serverless Managed serverless Managed containers Edge Workers Self-hosted containers
Who runs the machine Vercel Netlify Render Cloudflare You
Long-running processes ✗ (functions) ✗ (functions) ✓ ✗ (isolates) ✓
Cost at rest Per seat / usage Per seat / usage Per service Per request VPS rent only
Egress Metered Metered Metered R2 = free Your provider's
Data residency Their regions Their regions Their regions Their edge Wherever you rack it
TLS / routing Automatic Automatic Automatic Automatic OpenResty + certbot, you own the renewal

Reach for Openship when the project must keep infra in-house, when a single €5–€20 VPS has to carry several apps that would each be a paid Render service, or when the same box must also host Postgres, Redis, and a mail engine. Stay on Vercel (codeAmani's primary host) for the marketing site, the dashboard, and anything where a git push to master shipping itself is worth more than owning the box.

flowchart LR
  A["Source<br/>GitHub repo · local folder · artifact"] --> B["Detect<br/>package.json · lockfile · openship.json"]
  B --> C["Build<br/>Docker image or bare release<br/>config frozen into a snapshot"]
  C --> D["Run<br/>container on loopback only<br/>never a public port"]
  D --> E["Route + secure<br/>OpenResty vhost + Let's Encrypt HTTP-01"]
  E --> F["Live domain<br/>https://app.example.com"]
  G["git push"] -.->|"webhook"| B
  H["CLI · dashboard · desktop · MCP"] --> I["Control plane API<br/>Hono + Postgres/PGlite"]
  I --> B

Official Documentation

Resource URL
Docs home https://openship.io/docs
Quickstart https://openship.io/docs/getting-started/quickstart
Installation https://openship.io/docs/getting-started/installation
Core concepts https://openship.io/docs/getting-started/core-concepts
First deployment https://openship.io/docs/getting-started/first-deployment
Architecture overview https://openship.io/docs/architecture/overview
Deploy from GitHub https://openship.io/docs/guides/deploy-from-github
CLI reference https://openship.io/docs/cli
API reference https://openship.io/docs/api
MCP endpoint https://openship.io/docs/mcp
Upstream repo https://github.com/oblien/openship
Pricing / Cloud https://openship.io/pricing

Install

The install script brings its own Node when the system one is older than 22; the package-manager install runs on the Node you already have.

# macOS / Linux — fastest path (bundles Node)
curl -fsSL https://get.openship.io | sh

# or via a package manager (needs Node 22+)
npm i -g openship
# Windows (docs' one-liner — verify it resolves before trusting it; see NOTES.md)
irm https://git.openship.io/windows | iex

Then run the wizard — it creates the first admin, wires your domain, and installs Openship as a boot service:

openship            # guided setup, then the control panel
openship open       # opens the dashboard (default http://localhost:3001)
openship status     # health
openship doctor     # diagnose a broken install

For CI and headless boxes, skip the wizard:

openship up                                             # install + start as a background service
openship up --public-url https://openship.example.com   # + serve the dashboard on your domain (edge + TLS)
openship up --foreground                                # attached to the terminal
openship stop
openship update

Which mode openship up picks

Host Mode What you get
Linux with Docker Compose (default, force with --compose) Full stack from published ghcr.io/oblien/* images — Postgres, Redis, API, dashboard, and a containerized OpenResty edge on :80/:443. This is the flavour that hosts your deployed apps on the same box.
macOS / Windows / Linux without Docker bare (force with --bare) One lightweight process with an embedded database. An always-on control plane that deploys out to a server over SSH or to Openship Cloud.

Default ports: API :4000, dashboard :3001, edge :80/:443 in Compose mode.

Raw Docker Compose (no CLI)

The self-hosted stack lives in docker/docker-compose.yml and pulls published images — no monorepo compile.

git clone https://github.com/oblien/openship.git && cd openship
cp .env.example .env          # then edit
docker compose --env-file .env -f docker/docker-compose.yml up -d

Linux only (the edge uses network_mode: host). The api container mounts the host Docker socket so the control plane can build and run your apps as host containers — that is host-privileged through the socket, so run it only on a trusted host.

The root docker-compose.yml is a different file — it is the from-source control plane (builds from source, ships the marketing site, no edge, no socket). It does not self-host your apps.


Deploy a project

cd your-project
openship init            # link this directory to a project
openship deploy
openship deploy --watch  # follow build logs
openship logs

Or from the dashboard: Library → Repositories, pick the repo, choose the target (Local / Your Server / Cloud), review the detected framework + build command + domain, press Deploy, and watch the logs stream.

Turn on CI:

openship project create --name my-app --git-owner codeAmani-Solutions --git-repo my-app
openship project git auto-deploy my-app --enable

A GitHub webhook then re-runs the pipeline on every push to the tracked branch — rebuilding only the services a monorepo push actually touched.

Push-to-deploy and public domains need an always-on server or Cloud. A desktop/loopback instance has no public endpoint for GitHub to call.

Detected stacks

Node, Python, Go, Rust, PHP, Ruby, Java, .NET, plain Docker images, Docker Compose files deployed as-is, and monorepos. Zero config files are required; an openship.json in the repo overrides the guesses when you want control.


CLI map

Group Commands
Run / update the instance up, stop, update, install, open, status, doctor, reset-admin-password
Deploy init, deploy, deployment, logs
Resources project, service, domain
Access & API login, logout, context, token, api
Edge & monitoring edge
Self-host infra server, system, mail, backup

Global flags: --json (machine-readable — the one to use from scripts and agents), --version, --help.

Auth uses personal access tokens prefixed opsh_pat_:

openship login                                 # interactive
openship login --token opsh_pat_xxxxxxxxxxxx   # non-interactive / CI

Multiple instances are named contexts, stored in ~/.openship/config.json:

openship context            # list
openship context use prod   # switch

Shell completion — the static file is the fast option (regenerate after upgrades):

openship completion bash > /etc/bash_completion.d/openship
openship completion zsh  > ~/.zsh/completions/_openship
openship completion fish > ~/.config/fish/completions/openship.fish

Architecture

flowchart TD
  subgraph UI["Interfaces"]
    D1["Dashboard · Next.js"]
    D2["CLI · npm openship"]
    D3["Desktop · Electron"]
    D4["MCP · /api/mcp"]
  end
  UI -->|"HTTP /api/*"| API["Control plane API · Hono<br/>projects · deployments · domains<br/>env vars · backups · permissions"]
  API --> DB[("Postgres + Drizzle<br/>or embedded PGlite")]
  API -->|"getPlatform()"| ADP["@repo/adapters"]
  ADP --> R["runtime<br/>Docker · bare process · cloud"]
  ADP --> I2["infra<br/>OpenResty edge · certbot"]
  ADP --> S["system<br/>docker/git prereq checks"]
  R --> T{"Target"}
  T --> T1["Local machine"]
  T --> T2["Your server over SSH"]
  T --> T3["Openship Cloud"]

Core concepts

Term Meaning
Project One app. Remembers where the code comes from, how to build it, and everything attached.
Service A piece of a multi-part project — website, database, cache — running side by side.
Deployment One attempt to build and publish. Successful releases get version numbers (v1, v2…).
Environment A separate copy of the project: Production or Preview.
Domain The address people type, e.g. app.example.com.
Runtime / target Where the app actually runs: Local, Your Server (SSH), or Openship Cloud.

Driving Openship from Claude Code (MCP)

Openship exposes a single MCP endpoint at POST /api/mcp. Only routes that opt in become tools, every call re-runs the full auth and permission stack, and credential/token routes can never become tools.

# OAuth 2.1 — browser consent on first use (recommended)
claude mcp add --transport http --scope user openship https://openship.example.com/api/mcp

Static-token fallback:

claude mcp add --transport http openship https://openship.example.com/api/mcp \
  --header "Authorization: Bearer opsh_pat_…"
{
  "mcpServers": {
    "openship": {
      "url": "https://openship.example.com/api/mcp",
      "headers": { "Authorization": "Bearer opsh_pat_…" }
    }
  }
}

Tools map onto REST routes — get_projects, get_deployments, post_deployments_build_access, get_domains, post_domains, get_github_repos, get_analytics, get_jobs, post_jobs_by_key_run. Call tools/list to see exactly what your token can reach; a read-only token yields only read tools.

Use the public HTTPS origin, not localhost, so OAuth discovery and consent resolve in the browser.


codeAmani's fork — the local fleet control plane

codeAmani does not run stock Openship. The working checkout lives in WSL Ubuntu at ~/projects/grok-projects/open-ship:

origin https://github.com/codeAmani-Solutions/open-ship.git
upstream https://github.com/oblien/openship.git (merged in; currently v0.6.5)
Shape Bun 1.3 + Turbo monorepo — apps/{api,cli,dashboard,desktop,edge,email,web}, packages/{adapters,core,db,db-email,onboarding,ui}
License Apache-2.0 (unchanged)

Upstream's bun dev ports (:4000 / :3001) are not what this machine runs — 127.0.0.1:4000 is reserved for the globally installed openship CLI. The fleet topology is supplied by docker-compose.override.yml:

Surface Host port URL
Dashboard 3246 http://127.0.0.1:3246
API 3247 http://127.0.0.1:3247/api/health
Marketing web 3236 http://127.0.0.1:3236
Deploy-agent SSH 2222 ssh -p 2222 -i ssh-keys/id_ed25519 root@127.0.0.1
Webmail 20000 http://127.0.0.1:20000
Public dashboard Cloudflare tunnel https://open-ship.codeamani.com → :3246
Public API Cloudflare tunnel https://open-ship-api.codeamani.com → :3247

Bring-up (from the repo root, inside WSL):

hazina inject open-ship --check    # names only — never values
hazina inject open-ship            # writes .env.local

docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
node scripts/dev-status.mjs        # host + merged product issues

What the fork adds

1. deploy-agent/ — an SSH target the control plane can actually reach. When the API runs in Compose it cannot SSH to the WSL host (no sudo, no host sshd), so the fork ships an Ubuntu 24.04 container that is the deploy target: openssh-server with PasswordAuthentication no / PermitRootLogin prohibit-password, plus docker-ce-cli, docker-compose-plugin, and — critically — docker-buildx-plugin, because Openship runs docker build --progress=plain and the legacy builder rejects --progress with exit 125. It runs network_mode: host and mounts the host Docker socket, /var/lib/openship, and /etc/letsencrypt — that last bind is a fork fix: certsExist() inspects the deploy-agent's filesystem over SSH, so without it SSL verification cannot see certificates the edge already issued.

2. A product-issue operator loop. Containers up ≠ platform healthy — Openship has its own outage feed, so the fork wraps it in scripts:

node scripts/openship-api.mjs issues    # session-cookie API client
node scripts/dev-status.mjs             # host state + merged product issues
node scripts/dev-status.mjs --strict    # non-zero exit if product outages exist
node scripts/fleet-reconcile.mjs        # recycle the SSH pool + rebind the webmail container
node scripts/ops-watch.mjs --once       # DONE / FAILED

fleet-reconcile.mjs exists because after a deploy-agent recreate the API's SSH pool stays pinned to the dead connection and GET /issues reports the agent unreachable even though ssh -p 2222 works. Reconcile PATCHes the server to invalidate the pool and rewrites deployment.container_id for the host-network webmail.

3. Agent MCP over subscription logins. The fork documents connecting Grok Build and Claude Code to the instance over Openship's OAuth — using grok login / claude login subscriptions, not XAI_API_KEY or ANTHROPIC_API_KEY:

claude mcp add --transport http --scope user openship \
  https://open-ship.codeamani.com/api/mcp

grok mcp add --transport http openship \
  https://open-ship.codeamani.com/api/mcp

OAuth resource/issuer is the public dashboard origin. A PAT (Authorization: Bearer ${OPENSHIP_MCP_TOKEN}) is the local fallback — and that is an Openship token, not an xAI or Anthropic key. Never hand-edit ~/.claude.json; use claude mcp add.

4. Webmail / apps/email work. Mailbox identity (display names, colours, BIMI-style brand avatars), a single CID-embedded HQ signature image, an Outlook-shaped settings shell (Mail + Accounts), a richer compose toolbar, PWA offline support, and OpenPGP passphrase encryption on send (apps/email/server/src/lib/encrypt-pgp.ts). The mail engine and its database are not in the compose file — Openship installed them as managed containers.

Secrets: Hazina, not .env by hand

Every secret for this checkout flows through the Hazina vault project open-ship. Two files, deliberately not collapsed:

File Owner Consumed by
.env Compose stack docker compose env_file for api / dashboard
.env.local hazina inject open-ship scripts/auto-login-local.mjs, operator tooling

Do not env_file: .env.local into compose — mailbox passwords must never enter the API container. Bound names include OPENSHIP_ADMIN_EMAIL, OPENSHIP_ADMIN_PASSWORD, BETTER_AUTH_SECRET, INTERNAL_TOKEN, POSTGRES_PASSWORD, CLOUDFLARE_TUNNEL_TOKEN, PORKBUN_API_KEY / PORKBUN_SECRET_KEY, RESEND_API_KEY, and OPENSHIP_MCP_TOKEN. Values are revealed only on the operator machine (hazina get open-ship/… --reveal) and never printed in chat. See docs/HAZINA.md and docs/DEV-WORKFLOW.md in the fork.


Environment variables

Self-hosting keys read from .env (see .env.example in the repo):

OPENSHIP_VERSION=            # pin a release for reproducible image pulls
OPENSHIP_PUBLIC_URL=         # public origin the dashboard/API are served on
OPENSHIP_BIND_ADDR=127.0.0.1 # which interface published ports bind to
TRUST_PROXY=                 # set when a reverse proxy / tunnel fronts the stack
API_PORT=4000
DASHBOARD_PORT=3001
POSTGRES_PASSWORD=
BETTER_AUTH_SECRET=
INTERNAL_TOKEN=
GITHUB_CLIENT_ID=            # optional: GitHub OAuth device flow on self-hosted

CLI-side, an opsh_pat_… token authenticates openship login --token and the MCP header.


codeAmani notes


Troubleshooting

Issue Fix
openship open fails / API not responding openship up, wait, retry. openship doctor for a full diagnosis.
Build failed Read the last red lines of the build log — usually a missing build command, an unset env var, or a wrong port.
Host operations hang (:80/:443 takeover, mail engine, host terminal) The container→host SSH channel is missing. openship up provisions it; raw Compose does not — the five manual steps are in .env.example under Host operations from the container. See https://openship.io/docs/troubleshooting/host-channel
docker build exits 125 on a custom deploy target Openship passes --progress=plain; the legacy builder rejects it. Install docker-buildx-plugin.
SSL verify says "no certs" although the edge issued them certsExist() reads the deploy target's filesystem — bind-mount the host /etc/letsencrypt into it.
Agent unreachable after recreating the deploy target The API's SSH pool is pinned to the dead connection. Reconcile the server record (fork: node scripts/fleet-reconcile.mjs).
Dashboard /api/health 404 The dashboard has no health route — probe /login returning 200 instead.
openship update refuses to touch a raw-Compose stack update only reconciles a stack the CLI installed. Pin OPENSHIP_VERSION and run docker compose … pull && … up -d.
Public dashboard 502 behind a tunnel Check the tunnel origins point at the real dashboard/API ports, not the CLI's :4000.
npm i -g openship fails on the Node version The CLI needs Node 22+. Use the get.openship.io install script instead — it bundles its own Node.

Official docs:

pgvector Integration Guide

Technology: pgvector · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/pgvector

Insight:

The cheapest retrieval layer: pgvector is a Postgres extension, so vectors live in the Supabase/Neon database you already pay for — next to your relational rows. That unlocks SQL joins between embeddings and business data and RLS for per-tenant isolation, all under one backup and one bill. The trade vs. a dedicated vector DB (see [[pinecone]]): you bring your own embeddings (no integrated inference) and tune the index (HNSW vs IVFFlat) yourself. Ideal up to a few million vectors; beyond that, reach for Pinecone.

██████╗  ██████╗ ██╗   ██╗███████╗ ██████╗████████╗ ██████╗ ██████╗
██╔══██╗██╔════╝ ██║   ██║██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
██████╔╝██║  ███╗██║   ██║█████╗  ██║        ██║   ██║   ██║██████╔╝
██╔═══╝ ██║   ██║╚██╗ ██╔╝██╔══╝  ██║        ██║   ██║   ██║██╔══██╗
██║     ╚██████╔╝ ╚████╔╝ ███████╗╚██████╗   ██║   ╚██████╔╝██║  ██║
╚═╝      ╚═════╝   ╚═══╝  ╚══════╝ ╚═════╝   ╚═╝    ╚═════╝ ╚═╝  ╚═╝

pgvector Integration Guide

Focus: Vector search inside your existing Postgres (Supabase or Neon) — the cheapest RAG retrieval layer because it adds no new service. Store embeddings in a vector column, query with distance operators, and index with HNSW. The counterpoint to the managed pinecone guide.

Overview

Here is the whole pgvector journey at a glance — once you see the loop, the SQL below clicks into place.

flowchart LR
  A["Your text"] --> B["Embedding model<br/>Vercel AI SDK"]
  B --> C["Vector"]
  C --> D["Postgres documents table<br/>vector column"]
  E["User query"] --> B
  D --> F["Similarity search<br/>distance operator"]
  F --> G["Nearest neighbors<br/>JOIN to business rows"]

pgvector is an open-source Postgres extension that adds a vector column type plus similarity-search operators and indexes. Because it lives in Postgres:

You bring your own embeddings (e.g. via the Vercel AI SDK / Gemini / OpenAI) — pgvector stores and searches them but does not generate them (unlike Pinecone's integrated inference). Both Supabase and Neon ship pgvector; you just enable the extension. For local dev run the identical extension in a container — pgvector/pgvector:0.8.6-pg18-trixie (see the [[local-database]] guide) — so your HNSW index and operator choices are tested offline before they hit a hosted DB.

Current version: pgvector 0.8.6 (verify with SELECT extversion FROM pg_extension WHERE extname = 'vector';). Since 0.7.0 pgvector also ships halfvec (16-bit float), bit (binary), and sparsevec types plus the <+> L1 operator; 0.6.0 added parallel HNSW builds (2 workers by default) and 0.8.0 added iterative index scans (available but off by default — opt in with SET hnsw.iterative_scan). Supabase/Neon track upstream, but the installed version can lag the tag above.

Official Documentation

Resource URL
pgvector (core) https://github.com/pgvector/pgvector
pgvector-node https://github.com/pgvector/pgvector-node
Supabase pgvector https://supabase.com/docs/guides/database/extensions/pgvector
Neon pgvector https://neon.tech/docs/extensions/pgvector

1. Enable + create the table

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id        bigserial PRIMARY KEY,
  content   text,
  embedding vector(1536)        -- match your embedding model's dimension
);

2. Insert + query (raw SQL)

Distance operators: <-> L2/Euclidean, <=> cosine, <#> negative inner product, <+> L1/taxicab (since 0.7.0). Cosine (<=>) is the default for text embeddings.

INSERT INTO documents (content, embedding) VALUES ('hello world', '[0.1, 0.2, ...]');

-- nearest neighbors by cosine distance
SELECT id, content
FROM documents
ORDER BY embedding <=> '[0.05, 0.18, ...]'
LIMIT 5;

3. Index for speed (HNSW)

Picking an index is a quick, friendly decision — this tree gets you there in one question.

flowchart TD
  Q1{"Read-heavy set?"} -->|"yes"| A["HNSW<br/>best recall and latency"]
  Q1 -->|"smaller or write-heavy"| B["IVFFlat<br/>lighter-weight"]
  A --> C["Match ops class<br/>to distance operator"]
  B --> C
-- Build an approximate-nearest-neighbor index matching your query operator
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
-- (IVFFlat is the lighter-weight alternative for smaller / write-heavy sets)

4. Tuning the index — recall vs speed

The defaults work, but every index has build-time knobs (set once, in the CREATE INDEX ... WITH (...)) and a query-time knob (set per session or per query). The query-time knob is the live dial: turn it up for better recall, down for lower latency — no rebuild needed.

This flowchart picks the one knob to reach for first.

flowchart TD
  Q1{"Recall too low?"} -->|"yes · HNSW"| A["Raise hnsw.ef_search<br/>per query · no rebuild"]
  Q1 -->|"yes · IVFFlat"| B["Raise ivfflat.probes<br/>per query · no rebuild"]
  Q1 -->|"build is the bottleneck"| C["HNSW · lower ef_construction<br/>IVFFlat · fewer lists"]
  A --> D["Still low · rebuild HNSW<br/>with higher m and ef_construction"]

HNSW knobs

-- Build-time (set once): higher = better recall, slower build / inserts
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);   -- defaults: m = 16, ef_construction = 64

-- Query-time (live dial): higher = better recall, slower query. Default 40.
SET hnsw.ef_search = 100;                -- whole session
-- ...or just for one query, scoped to a transaction:
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, content FROM documents ORDER BY embedding <=> '[...]' LIMIT 5;
COMMIT;

IVFFlat knobs

-- Build-time (set once): lists ≈ rows / 1000 up to 1M rows, sqrt(rows) above 1M.
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

-- Query-time (live dial): higher = better recall, slower query. Default 1.
SET ivfflat.probes = 10;                 -- whole session
-- ...or per query:
BEGIN;
SET LOCAL ivfflat.probes = 10;
SELECT id, content FROM documents ORDER BY embedding <=> '[...]' LIMIT 5;
COMMIT;

Gotcha: build an IVFFlat index only after the table has data — it clusters existing rows into lists, so an empty-table build yields poor recall. HNSW has no such requirement, but both build far faster when the index fits in maintenance_work_mem (Postgres warns when it doesn't).

5. Node (node-postgres) — store your own embeddings

import pgvector from "pgvector/pg";
import { embed } from "ai"; // Vercel AI SDK generates the vector

await pgvector.registerTypes(client); // register the vector type once

const { embedding } = await embed({ model: "openai/text-embedding-3-small", value: text });
await client.query("INSERT INTO documents (content, embedding) VALUES ($1, $2)", [
  text,
  pgvector.toSql(embedding),
]);

const { rows } = await client.query(
  "SELECT id, content FROM documents ORDER BY embedding <=> $1 LIMIT 5",
  [pgvector.toSql(queryEmbedding)],
);

pgvector also has adapters for Prisma, Drizzle, Sequelize, and TypeORM; Python uses the pgvector package (+ Supabase's vecs client).

codeAmani notes

Official docs:

Pinecone Integration Guide

Technology: pinecone · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/pinecone

Insight:

This directly solves the stated gap ("lots of AI, no retrieval layer"). Pinecone's integrated inference is the key: createIndexForModel binds an embedding model to the index, so you upsertRecords/searchRecords with raw text — Pinecone embeds server-side, meaning zero separate embeddings infrastructure to stand up. Two codeAmani wins: Pinecone's hosted multilingual-e5-large embeds Swahili + English in the same space (East-African content works out of the box), and retrieved chunks feed straight into a Claude call via the Vercel AI SDK — a full RAG loop across two guides.

██████╗ ██╗███╗   ██╗███████╗ ██████╗ ██████╗ ███╗   ██╗███████╗
██╔══██╗██║████╗  ██║██╔════╝██╔════╝██╔═══██╗████╗  ██║██╔════╝
██████╔╝██║██╔██╗ ██║█████╗  ██║     ██║   ██║██╔██╗ ██║█████╗
██╔═══╝ ██║██║╚██╗██║██╔══╝  ██║     ██║   ██║██║╚██╗██║██╔══╝
██║     ██║██║ ╚████║███████╗╚██████╗╚██████╔╝██║ ╚████║███████╗
╚═╝     ╚═╝╚═╝  ╚═══╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝  ╚═══╝╚══════╝

Pinecone Integration Guide

Focus: The retrieval layer for codeAmani's AI. Pinecone is a serverless vector database for RAG — store knowledge, retrieve the most relevant chunks, and feed them into a Claude prompt. With integrated inference you skip running an embedding model entirely; Pinecone embeds text server-side (incl. multilingual-e5-large for Swahili+English).

Overview

Two ways to use Pinecone — pick based on whether you want Pinecone to do the embedding:

Namespaces partition an index (e.g. per tenant/user) — the multi-tenant isolation primitive. In Claude Code, the Pinecone MCP (npx -y @pinecone-database/mcp) can search the docs, create indexes, and upsert/search records directly — see §5.

Here is the integrated-inference path at a glance — notice the embedding happens server-side, so your app never touches a vector:

flowchart LR
  A["Raw text records"] -->|"upsertRecords"| B["Index bound to<br/>multilingual-e5-large"]
  B --> C["Vectors stored<br/>server-side"]
  Q["Text query"] -->|"searchRecords topK"| C
  C --> D["Optional rerank<br/>bge-reranker-v2-m3 topN"]
  D --> E["Top matching chunks"]

RAG decision notes

The headline trade-off — Pinecone's integrated inference vs. running your own embeddings — is summarized in the Insight callout at the top of this page. A few more decision points worth knowing:

Chunking strategy

Since chunking dominates retrieval quality (see the note above), here is concrete guidance — all sizes are in tokens, not characters.

Size — start fixed, then iterate. Pinecone's chunking guide recommends starting with fixed-size chunking and only moving to fancier strategies once it proves insufficient. For sizing, the guide says to "start by exploring a variety of chunk sizes, including smaller chunks (e.g. 128 or 256 tokens) ... and larger chunks (e.g. 512 or 1024 tokens)" (pinecone.io/learn/chunking-strategies). Practical default for FAQ/support docs: ~512 tokens. Smaller chunks give sharper, more precise matches; larger chunks carry more context per hit but dilute the embedding.

Overlap — 10–20% is the commonly-documented range. Pinecone's guide itself does not prescribe a fixed overlap percentage; it instead favours chunk expansion (pulling neighbouring chunks at query time) to recover context. Across the broader RAG literature, a 10–20% overlap (e.g. 50–100 tokens on a 512-token chunk) is the widely-cited starting point to avoid splitting a sentence's meaning across a boundary. Treat it as a knob to tune, not gospel — some recent benchmarks find overlap adds indexing cost with little gain, so measure on your own corpus.

Semantic vs fixed splitting — when to switch:

Keep chunk_text (the field in your index fieldMap) as the only text field per record, and store source metadata (doc_id, section, chunk_index) so you can rebuild context.

// Fixed-size, overlapping chunker — run BEFORE upsertRecords (§2).
// Approximate tokens with a chars-per-token ratio (English ~4; tune for Swahili).
const CHARS_PER_TOKEN = 4;

function chunkText(
  text: string,
  { chunkTokens = 512, overlapTokens = 80 } = {},
): string[] {
  const size = chunkTokens * CHARS_PER_TOKEN;        // ~2048 chars
  const overlap = overlapTokens * CHARS_PER_TOKEN;    // ~320 chars
  const stride = Math.max(size - overlap, 1);         // guard: overlap < size
  const chunks: string[] = [];
  for (let start = 0; start < text.length; start += stride) {
    const slice = text.slice(start, start + size).trim();
    if (slice) chunks.push(slice);
  }
  return chunks;
}

// Wire chunks into the §2 integrated-inference upsert:
const records = chunkText(sourceDoc).map((chunk, i) => ({
  id: `faq-mpesa#${i}`,           // stable id = doc + chunk index
  chunk_text: chunk,             // must match the index fieldMap
  doc_id: "faq-mpesa",
  chunk_index: i,
  topic: "payments",
}));
await index.upsertRecords({ records });

Gotcha: chunk sizes are measured in tokens, but most splitters (including the char-based helper above) cut on characters. The ~4-chars-per-token ratio is an English approximation — Swahili and other non-English text often run fewer chars per token, so a "512-token" char window can silently overshoot the embedding model's context limit and get truncated server-side. For production, count with a real tokenizer (e.g. tiktoken / js-tiktoken) instead of a fixed ratio, and verify against your model's window (multilingual-e5-large ≈ 507 tokens; llama-text-embed-v2 = 2048 tokens).

flowchart TD
  A["Source doc"] --> B{"Structured doc<br/>headings · sections"}
  B -->|"No · uniform prose"| C["Fixed-size<br/>~512 tok · 10-20% overlap"]
  B -->|"Yes"| D{"Topics shift<br/>a lot"}
  D -->|"No"| E["Content-aware<br/>recursive split"]
  D -->|"Yes · long PDF"| F["Semantic<br/>split on topic shift"]
  C --> G["upsertRecords<br/>chunk_text field"]
  E --> G
  F --> G

Official Documentation

Resource URL
Docs home https://docs.pinecone.io/
Quickstart https://docs.pinecone.io/guides/get-started/quickstart
Integrated inference https://docs.pinecone.io/guides/inference/understanding-inference
Chunking strategies https://www.pinecone.io/learn/chunking-strategies/
TypeScript client https://github.com/pinecone-io/pinecone-ts-client
Python SDK https://github.com/pinecone-io/python-sdk

1. Install + credentials

npm install @pinecone-database/pinecone     # JS/TS — v8.x
pip install pinecone                         # Python 3.10+ — v9.x
PINECONE_API_KEY=...    # server-side only (.env.local / Vercel env, or Infisical)

Optional — the pc CLI (for scripting index/project ops outside the app):

brew install pinecone-io/tap/pinecone       # macOS/Linux (Homebrew)
# or: curl -fsSL https://pinecone.io/install.sh | sh
pc auth login                                # browser auth
pc index create --name kb --dimension 1024 --metric cosine --cloud aws --region us-east-1
pc index list

2. RAG with integrated inference (no embedding layer) — TypeScript

import { Pinecone } from "@pinecone-database/pinecone";

const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });

// 2a. Create an index bound to an embedding model (multilingual = Swahili + English)
const model = await pc.createIndexForModel({
  name: "kb",
  cloud: "aws",
  region: "us-east-1",
  embed: { model: "multilingual-e5-large", fieldMap: { text: "chunk_text" } },
  waitUntilReady: true,
});
const index = pc.index({ host: model.host });

// 2b. Upsert RAW TEXT — Pinecone embeds it server-side
await index.upsertRecords({
  records: [
    { id: "doc1", chunk_text: "M-Pesa STK Push prompts the user on their phone.", topic: "payments" },
    { id: "doc2", chunk_text: "Daraja tokens expire after one hour.", topic: "payments" },
  ],
});

// 2c. Search with a text query (+ optional rerank)
const hits = await index.searchRecords({
  query: { topK: 4, inputs: { text: "how does M-Pesa checkout work?" }, filter: { topic: { $eq: "payments" } } },
  rerank: { model: "bge-reranker-v2-m3", topN: 2, rankFields: ["chunk_text"] },
  fields: ["chunk_text", "topic"],
});

3. Close the RAG loop (Pinecone → Claude via the AI SDK)

You are one short hop from a full answer — retrieved chunks become Claude's context, with Pinecone as memory and Claude as generator:

sequenceDiagram
  participant U as "User"
  participant App as "Server action"
  participant P as "Pinecone"
  participant C as "Claude · AI SDK"
  U->>App: "Question"
  App->>P: "searchRecords · text query"
  P-->>App: "Top-k chunks"
  App->>C: "Prompt with context"
  C-->>App: "Grounded answer"
  App-->>U: "Reply"
import { generateText } from "ai";

const context = hits.result.hits.map(h => h.fields.chunk_text).join("\n---\n");
const { text } = await generateText({
  model: "anthropic/claude-sonnet-5",               // via Vercel AI Gateway (Claude 5 family)
  prompt: `Answer using ONLY this context:\n${context}\n\nQ: How does M-Pesa checkout work?`,
});

4. Bring-your-own-vectors (Python, explicit dimension)

from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key="...")
pc.indexes.create(name="kb", dimension=1536, metric="cosine",
                  spec=ServerlessSpec(cloud="aws", region="us-east-1"))
index = pc.index("kb")   # v9: lowercase pc.index(); capital pc.Index() is deprecated
index.upsert(vectors=[("id1", embedding_1536d)], namespace="tenant-a")
res = index.query(vector=query_vec, top_k=10, namespace="tenant-a")

5. Claude Code — Pinecone MCP + pc CLI

The Pinecone MCP (npx -y @pinecone-database/mcp, PINECONE_API_KEY in the env) lets an agent build and inspect indexes without leaving the editor. Current tools:

Tool Use
search-docs Look up current Pinecone docs
list-indexes / describe-index / describe-index-stats Inspect indexes + record counts
create-index-for-model Create an integrated-inference index
upsert-records / search-records Write and query raw-text records
rerank-documents Rerank a candidate list
cascading-search Search across multiple indexes and merge

The MCP supports integrated-embedding indexes only — bring-your-own-vector indexes are not addressable through it. For scripting and CI, the standalone pc CLI (see §1) covers auth, project targeting, and index/vector CRUD.

codeAmani notes

Official docs:

Plausible Analytics Integration Guide

Technology: plausible · Category: monitoring · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/plausible

Insight:

Plausible is the web-analytics layer — cookieless and GDPR/HIPAA-friendly, so provider sites need no consent banner (a real win for Florida healthcare clients). Proxy the script per provider site so ad-blockers can't blind it, define a Lead goal for contact-form conversions, and pull the Stats API v2 into the analytics_daily rollup with a daily Upstash QStash cron so /portal/analytics reads from Postgres, not a third party.

██████╗ ██╗      █████╗ ██╗   ██╗███████╗██╗██████╗ ██╗     ███████╗
██╔══██╗██║     ██╔══██╗██║   ██║██╔════╝██║██╔══██╗██║     ██╔════╝
██████╔╝██║     ███████║██║   ██║███████╗██║██████╔╝██║     █████╗
██╔═══╝ ██║     ██╔══██║██║   ██║╚════██║██║██╔══██╗██║     ██╔══╝
██║     ███████╗██║  ██║╚██████╔╝███████║██║██████╔╝███████╗███████╗
╚═╝     ╚══════╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚══════╝╚══════╝

Plausible Analytics Integration Guide

Focus: Privacy-first, cookieless web analytics for multi-tenant provider sites — script embedding (proxied), custom-event goals, and pulling the Stats API v2 into a Postgres rollup that powers the in-app /portal/analytics dashboard.

Overview

Plausible is an open-source, lightweight (~1 KB script), privacy-friendly analytics platform — a cookie-free alternative to Google Analytics. It collects no personal data and sets no cookies, so sites that embed it need no consent banner under GDPR/CCPA and it sidesteps most PII concerns under HIPAA — a direct fit for Florida healthcare provider sites where a tracking-cookie banner is both friction and a liability.

For the Motionstack Dashboard, Plausible is the data source behind the provider-facing analytics_daily rollup. Each provider site (/sites/[subdomain]) embeds a proxied Plausible script; a daily job reads the Stats API v2 per site and upserts visitors/pageviews/bounce-rate/lead-count into Neon, so the /portal/analytics surface renders from our own Postgres instead of a third-party iframe.

flowchart LR
    A["Provider site<br/>/sites/[subdomain]"] --> B["Proxied script<br/>/pa/js/script.js"]
    B --> C["Plausible<br/>(Cloud or self-hosted CE)"]
    A -. "contact form" .-> D["plausible('Lead')<br/>custom-event goal"]
    D --> C
    C --> E["Stats API v2<br/>/api/v2/query"]
    E --> F["Daily QStash cron<br/>per provider"]
    F --> G[("analytics_daily<br/>Neon Postgres")]
    G --> H["/portal/analytics<br/>dashboard"]

Official Documentation

Resource URL
Plausible Docs https://plausible.io/docs
Stats API v2 (read) https://plausible.io/docs/stats-api
Events API (server-side) https://plausible.io/docs/events-api
Custom Event Goals https://plausible.io/docs/custom-event-goals
Script Extensions / plausible.init() https://plausible.io/docs/script-extensions
Script Proxy https://plausible.io/docs/proxy/introduction
Community Edition (self-host) https://github.com/plausible/community-edition
Official NPM tracker (@plausible-analytics/tracker) https://www.npmjs.com/package/@plausible-analytics/tracker
next-plausible https://github.com/4lejandrito/next-plausible

No official MCP server. Plausible exposes a REST Stats API, not an MCP server — integrate it as a normal HTTPS data source (see the Stats API section) rather than via claude mcp add.


Script Setup

The snippet (init-based script)

Plausible ships one lightweight script (~1 KB). When you add a site, the dashboard generates a site-specific snippet (its pa-XXXXX ID encodes your site / data-domain) that goes in <head>:

<script defer src="https://plausible.io/js/pa-XXXXX.js"></script>

Enhanced measurements — outbound links, file downloads, form submissions — are toggled in Site Settings → General → Tracking and take effect without editing the snippet. Advanced behavior is configured through plausible.init():

plausible.init() option Type Default Use
outboundLinks boolean false Track external link clicks
fileDownloads boolean | { fileExtensions } false Track PDF/intake-form downloads
formSubmissions boolean false Track form submissions
customProperties object | (eventName) => object {} Attach global props to every event
hashBasedRouting boolean false SPA hash routing
autoCapturePageviews boolean true Set false to fire pageviews manually
captureOnLocalhost boolean false Enable dev/localhost tracking
endpoint string https://plausible.io/api/event Point at a proxy / self-hosted host

Legacy note: older installs used filename-based extensions (script.tagged-events.js, script.outbound-links.js, script.manual.js, script.local.js, …). Those still resolve, but new sites get the init-based pa-XXXXX.js script above — prefer it. The official NPM build is @plausible-analytics/tracker (init() / track()); the old community plausible-tracker package is deprecated. next-plausible (below) wraps all of this for Next.js.

next-plausible@4 tracks Plausible's init-based script and is the cleanest path for the dashboard. Proxying serves the script and event endpoint from your own domain (/pa/...), which defeats ad-blockers (they block plausible.io, not first-party paths) and keeps all traffic first-party — important when a provider's visitors run uBlock.

pnpm add next-plausible
// next.config.ts
import { withPlausibleProxy } from "next-plausible";

export default withPlausibleProxy({
  // your site-specific script URL from the Plausible dashboard.
  // Self-hosted CE: use the instance URL, e.g. https://stats.motionstack.app/js/pa-XXXXX.js
  src: process.env.NEXT_PUBLIC_PLAUSIBLE_SRC!,
  // keep the first-party paths the rest of this guide references
  // (defaults are /js/script.js and /api/event):
  scriptPath: "/pa/js/script.js",
  apiPath: "/pa/api/event",
})({
  reactStrictMode: true, // a config object is mandatory, even if empty
});
// app/sites/[subdomain]/layout.tsx — per-provider tenant site
import PlausibleProvider from "next-plausible";

export default async function ProviderSiteLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      {/* v4 mounts the provider inside <body>, not <head>.
          `src` is omitted here because withPlausibleProxy wires it up automatically. */}
      <body>
        <PlausibleProvider
          init={{ outboundLinks: true }}
          enabled={process.env.NODE_ENV === "production"}
        >
          {children}
        </PlausibleProvider>
      </body>
    </html>
  );
}

v4 breaking change: the old domain / customDomain / selfHosted / trackOutboundLinks / taggedEvents props are gone. Enhanced measurements now live in Site Settings or the init object; self-hosting is just a different src. For true per-provider site IDs, pass a per-subdomain src={tenant.paScriptUrl} (that tenant's pa-XXXXX.js) instead of the shared proxy, or run one Plausible site with subdomain/hostname filtering. See next-plausible's MIGRATION.md when upgrading from v3.


Custom Events & Goals

A custom event only counts once you create a matching Goal in Plausible (Site Settings → Goals → "Custom event"). The flagship goal is Lead — fired when a provider's contact form is submitted, so analytics_daily.leads_count reconciles against Plausible.

// components/sites/LeadForm.tsx
"use client";
import { usePlausible } from "next-plausible";

type Events = {
  Lead: { provider: string; source: string };
  Download: { file: string };
};

export function LeadForm({ providerSlug }: { providerSlug: string }) {
  const plausible = usePlausible<Events>();

  async function onSubmit(formData: FormData) {
    await fetch("/api/leads", { method: "POST", body: formData });
    // goal conversion — props power the Plausible breakdown view
    plausible("Lead", { props: { provider: providerSlug, source: "site-form" } });
  }

  return <form action={onSubmit}>{/* … */}</form>;
}

Server-side conversions (Events API)

For conversions that happen off the page (e.g. a Stripe webhook confirming an upsell), record them server-side. You must forward the visitor's User-Agent and IP via X-Forwarded-For or Plausible cannot attribute the event:

// lib/plausible/event.ts
export async function trackServerEvent(opts: {
  name: string;
  domain: string;          // the site ID
  url: string;             // canonical page URL
  userAgent: string;
  ip: string;
  props?: Record<string, string | number | boolean>;
  revenue?: { currency: string; amount: number };
}): Promise<void> {
  const host = process.env.PLAUSIBLE_HOST ?? "https://plausible.io";
  const res = await fetch(`${host}/api/event`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "User-Agent": opts.userAgent,
      "X-Forwarded-For": opts.ip,
    },
    body: JSON.stringify({
      name: opts.name,
      url: opts.url,
      domain: opts.domain,
      props: opts.props,
      revenue: opts.revenue,
    }),
  });
  if (!res.ok) throw new Error(`Plausible event failed: ${res.status}`);
}

Stats API v2 → analytics_daily rollup

This is the core integration. The Stats API v2 is a single POST /api/v2/query endpoint, authenticated with a Bearer API key (Plausible account → Settings → API Keys).

curl https://plausible.io/api/v2/query \
  --request POST \
  --header "Authorization: Bearer $PLAUSIBLE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "site_id": "acme.providers.motionstack.app",
    "metrics": ["visitors", "pageviews", "bounce_rate"],
    "date_range": "7d",
    "dimensions": ["time:day"]
  }'

Daily job that fans out over every active provider and upserts the rollup (run it on an Upstash QStash schedule — see the upstash guide — so it survives serverless cold starts and retries):

// app/api/cron/plausible-rollup/route.ts
import { db } from "@/lib/db";
import { providers, analyticsDaily } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";

const PLAUSIBLE = process.env.PLAUSIBLE_HOST ?? "https://plausible.io";

type Row = { date: string; visitors: number; pageviews: number; bounce_rate: number };

// Stats API v2 has NO "yesterday" shortcut — a single day is a custom [date, date] range.
function yesterdayISO(): string {
  return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); // YYYY-MM-DD
}

async function queryDay(siteId: string): Promise<Row[]> {
  const day = yesterdayISO();
  const res = await fetch(`${PLAUSIBLE}/api/v2/query`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PLAUSIBLE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      site_id: siteId,
      metrics: ["visitors", "pageviews", "bounce_rate"],
      date_range: [day, day], // single day; v2 has no "yesterday" preset
      dimensions: ["time:day"],
    }),
  });
  if (!res.ok) throw new Error(`Stats API ${res.status} for ${siteId}`);
  const json = (await res.json()) as {
    results: { dimensions: string[]; metrics: number[] }[];
  };
  return json.results.map((r) => ({
    date: r.dimensions[0],
    visitors: r.metrics[0],
    pageviews: r.metrics[1],
    bounce_rate: r.metrics[2],
  }));
}

export async function POST(): Promise<Response> {
  const active = await db
    .select({ id: providers.id, subdomain: providers.subdomain })
    .from(providers)
    .where(eq(providers.status, "active"));

  for (const p of active) {
    const siteId = `${p.subdomain}.providers.motionstack.app`;
    try {
      for (const row of await queryDay(siteId)) {
        await db
          .insert(analyticsDaily)
          .values({
            providerId: p.id,
            date: row.date,
            visitors: row.visitors,
            pageviews: row.pageviews,
            bounceRate: String(row.bounce_rate),
          })
          .onConflictDoUpdate({
            target: [analyticsDaily.providerId, analyticsDaily.date],
            set: {
              visitors: row.visitors,
              pageviews: row.pageviews,
              bounceRate: String(row.bounce_rate),
            },
          });
      }
    } catch (err) {
      // surface to Sentry — see the `sentry` guide — don't fail the whole batch
      console.error(`rollup failed for ${siteId}`, err);
    }
  }
  return Response.json({ ok: true, providers: active.length });
}

Useful query knobs: metrics (visitors, visits, pageviews, bounce_rate, visit_duration, events, conversion_rate, total_revenue, …); date_range presets ("day", "24h", "7d", "28d", "30d", "91d", "month", "6mo", "12mo", "year", "all") — there is no "yesterday" shortcut, so a single day is a custom ["2026-08-22","2026-08-22"] array; dimensions (time:day, event:page, event:goal, visit:country, visit:source); filters (e.g. [["is","visit:country",["KE","US"]]]). The Stats API is rate-limited to 600 requests/hour per key, so fan out the rollup with that budget in mind. To reconcile leads_count, query with dimensions: ["event:goal"] filtered to the Lead goal.


Self-Hosting (Community Edition)

The dashboard can run against Plausible Cloud or a self-hosted Community Edition (CE) instance at stats.motionstack.app (data sovereignty + flat cost). CE bundles PostgreSQL and ClickHouse via Docker Compose. Pin the release tag (current: v3.2.1) and configure a .env file — CE reads configuration from .env, not the old plausible-conf.env:

git clone -b v3.2.1 --single-branch \
  https://github.com/plausible/community-edition plausible-ce
cd plausible-ce

# Configure the instance (SECRET_KEY_BASE must be at least a 64-byte string)
echo "BASE_URL=https://stats.motionstack.app" >> .env
echo "SECRET_KEY_BASE=$(openssl rand -base64 48)" >> .env

# HTTP_PORT=80 + HTTPS_PORT=443 enable automatic Let's Encrypt TLS (no Caddy needed);
# expose them via a compose override so the container can bind them.
echo "HTTP_PORT=80"  >> .env
echo "HTTPS_PORT=443" >> .env
cat > compose.override.yml <<'YML'
services:
  plausible:
    ports:
      - 80:80
      - 443:443
YML

docker compose up -d   # starts plausible + postgres + clickhouse (TLS is built-in)

TOTP_VAULT_KEY is no longer required — current CE derives it automatically; only BASE_URL and SECRET_KEY_BASE are mandatory. TLS is handled by the app's built-in automatic Let's Encrypt (there is no bundled Caddy reverse proxy anymore).

When self-hosted, point next-plausible's src at your instance's pa-XXXXX.js (e.g. https://stats.motionstack.app/js/pa-XXXXX.js) and set PLAUSIBLE_HOST for the server-side Events/Stats API calls. Everything else (script, Events API, Stats API v2) is identical to Cloud.


Environment Variables

# Stats API v2 + Events API (server-side only — never expose in the browser)
PLAUSIBLE_API_KEY=...                      # Bearer key from Plausible → Settings → API Keys

# Server-side API host: leave default for Cloud; set for self-hosted CE
PLAUSIBLE_HOST=https://plausible.io

# Site-specific script URL used by next-plausible v4 `src` (public — it is the snippet).
# Cloud: https://plausible.io/js/pa-XXXXX.js  |  CE: https://stats.motionstack.app/js/pa-XXXXX.js
NEXT_PUBLIC_PLAUSIBLE_SRC=https://plausible.io/js/pa-XXXXX.js

Add these to ENV_MASTER.md and each project's .env.example. The API key is server-only — it must never reach the client bundle.


Automation Workflows

QStash schedule (daily rollup)

# Register the cron once (03:15 UTC daily). See the `upstash` guide for QStash setup.
curl -X POST "https://qstash.upstash.io/v2/schedules/https://app.motionstack.app/api/cron/plausible-rollup" \
  -H "Authorization: Bearer $QSTASH_TOKEN" \
  -H "Upstash-Cron: 15 3 * * *"

Claude Code slash command: analytics snapshot

.claude/commands/analytics.md:

Summarize Plausible analytics for provider: $ARGUMENTS

1. Read PLAUSIBLE_API_KEY from the environment (do not print it).
2. POST to /api/v2/query for site "$ARGUMENTS.providers.motionstack.app" with
   metrics ["visitors","pageviews","bounce_rate","visit_duration"], date_range "30d",
   dimensions ["time:day"].
3. Also query dimensions ["event:goal"] to pull the "Lead" goal conversions.
4. Compare against the analytics_daily rows in Neon for the same window and flag any drift.
5. Output a short trend summary (WoW change) and any anomalies.

Common Use Cases

Use Case Approach
Per-provider site analytics Proxied PlausibleProvider per /sites/[subdomain], per-tenant src = that site's pa-XXXXX.js
Daily rollup into Postgres POST /api/v2/query per provider → upsert analytics_daily (QStash cron)
Lead conversion tracking Lead custom-event goal + plausible('Lead', …) on form submit
Off-page conversions Server-side Events API (POST /api/event) from Stripe webhook
Ad-blocker resistance withPlausibleProxy so the script is served first-party at /pa/...
No cookie banner (HIPAA/GDPR) Cookieless by design — nothing to consent to
Data sovereignty Self-hosted Community Edition at stats.motionstack.app

Troubleshooting

Issue Fix
No data appearing Confirm the site's domain in Plausible exactly matches (no https://, no trailing slash), and that the correct site's pa-XXXXX.js / src is loading
Events blocked by ad-blockers Use withPlausibleProxy (first-party /pa/... path) instead of the raw plausible.io script
Custom event not counting Create the matching Goal in Site Settings → Goals → + Add goal → Custom event (name must match exactly, char-for-char); goals are not backfilled, so fire the event again after creating it
Stats API 401 API key missing/expired, or site_id not owned by the key's account
Stats API 400 Invalid metric/dimension name or malformed date_range (e.g. the removed "yesterday" preset) — check spelling against the docs
Stats API 429 Over the 600 requests/hour per-key limit — back off / batch the rollup fan-out
Server event dropped (x-plausible-dropped: 1) Forward the visitor User-Agent and X-Forwarded-For; events from localhost/staging domains not added to the account are rejected by bot filtering
Localhost shows no data Production-only by default — set enabled (next-plausible) or init={{ captureOnLocalhost: true }} for dev testing
Self-hosted script 404 Verify BASE_URL in .env and that next-plausible's src / PLAUSIBLE_HOST point at the CE instance's pa-XXXXX.js

Official docs:

Porkbun Integration Guide

Technology: porkbun · Category: domains · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/porkbun

Insight:

Porkbun is the registrar + DNS — the repo's porkbun-dns skill manages records programmatically (it's how codeamanilabs.org subdomains like thumbs. and tech-stack. are wired). As of API v3.15 the surface is much bigger: domain registration/renewal/transfer are now API-supported (no longer dashboard-only), there's an official MCP server (@porkbunllc/mcp-server), and writes take an Idempotency-Key + dryRun. The codeAmani angle: hand agents a per-key scoped credential (restrict to specific domains / source-IP CIDR), keep both keys server-side, and expect a short gap as DNS propagates before TLS issues.

██████╗  ██████╗ ██████╗ ██╗  ██╗██████╗ ██╗   ██╗███╗   ██╗
██╔══██╗██╔═══██╗██╔══██╗██║ ██╔╝██╔══██╗██║   ██║████╗  ██║
██████╔╝██║   ██║██████╔╝█████╔╝ ██████╔╝██║   ██║██╔██╗ ██║
██╔═══╝ ██║   ██║██╔══██╗██╔═██╗ ██╔══██╗██║   ██║██║╚██╗██║
██║     ╚██████╔╝██║  ██║██║  ██╗██████╔╝╚██████╔╝██║ ╚████║
╚═╝      ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝  ╚═════╝ ╚═╝  ╚═══╝

Porkbun Integration Guide

Focus: Automating domain registration, DNS management, and SSL certificate workflows from Claude Code using the Porkbun REST API.

Overview

Porkbun is a domain registrar known for competitive pricing and a clean REST/JSON API. As of API v3.15 (2026) Porkbun ships a first-party MCP server (@porkbunllc/mcp-server) that exposes the whole API as native tools — plus you can still drive it directly with REST calls from Bash or Node.js. The v3.15 surface now covers the full domain lifecycle over the API — register, renew, transfer-in, DNS & DNSSEC CRUD, SSL bundle retrieval, URL forwarding, glue records, contacts, email forwarding, static hosting, and signed webhooks — with agent-safety features (machine-readable error code + next_action, Idempotency-Key, dryRun, and per-key IP/domain scoping). This guide shows how to wire Porkbun into your Claude Code automation workflow.

Here is the big picture — once you see how the pieces connect, the rest of this guide is just filling in the details:

flowchart LR
  A["Domain in<br/>Porkbun account"] --> B["API key plus<br/>Secret API key"]
  B --> C["REST API call<br/>POST to api.porkbun.com"]
  C --> D["Create DNS record<br/>A · CNAME · TXT · MX"]
  D --> E["DNS propagates"]
  E --> F["Domain resolves<br/>to your host"]
  F --> G["SSL cert provisioned<br/>HTTPS live"]

Official Documentation

Resource URL
Porkbun API Docs (v3.15) https://porkbun.com/api/json/v3/documentation
Full reference (one flat Markdown file) https://porkbun.com/llms-full.txt
Per-topic Markdown (dns, domain, webhooks…) https://porkbun.com/llms
OpenAPI 3.0 spec (JSON) https://porkbun.com/api/json/v3/spec
Mock server (schema-accurate, no auth) https://api.porkbun.com/api/json/v3/mock/<path>
Official MCP server https://github.com/oborseth/Porkbun-MCP (npx -y @porkbunllc/mcp-server)
Control Panel https://porkbun.com/account/domainsSpeedy
API keys / access + per-key scoping https://porkbun.com/account/api

API Setup

Get Your API Keys

  1. Log in at porkbun.com
  2. Go to Account → API Access (https://porkbun.com/account/api)
  3. Enable API access and generate your API key (pk1_…) and Secret API key (sk1_…)
  4. Still enable API access per-domain (domain settings → API Access toggle) — without it, calls for that domain return a not-found error even though you own it.
  5. Optional but recommended for agents: scope the key to specific domains and/or source-IP CIDRs so a leaked key can't touch your whole account.

Two auth methods (v3.15). You can send apikey/secretapikey in the JSON body (works on GET and POST) or X-API-Key/X-Secret-API-Key request headers. Header auth pairs naturally with the new GET form of read endpoints; writes are always POST. Keys work whether or not account 2FA is enabled. For a throwaway test environment, mint a sandbox key pair (pk1_sb_/sk1_sb_) — it runs the full API against an isolated account with fake credit, no real charges.

Test Your Credentials

# Body auth (POST) — works everywhere
curl -s https://api.porkbun.com/api/json/v3/ping \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "apikey": "'$PORKBUN_API_KEY'",
    "secretapikey": "'$PORKBUN_SECRET_API_KEY'"
  }' | jq .

# Header auth (GET) — v3.15
curl -s "https://api.porkbun.com/api/json/v3/ping" \
  -H "X-API-Key: $PORKBUN_API_KEY" \
  -H "X-Secret-API-Key: $PORKBUN_SECRET_API_KEY" | jq .

Expected response (valid creds add credentialsValid: true):

{ "status": "SUCCESS", "yourIp": "1.2.3.4", "credentialsValid": true }

API Integration

TypeScript Client Helper

// lib/porkbun.ts
const PORKBUN_BASE = "https://api.porkbun.com/api/json/v3";

const auth = {
  apikey: process.env.PORKBUN_API_KEY!,
  secretapikey: process.env.PORKBUN_SECRET_API_KEY!,
};

async function porkbun<T>(path: string, body: object = {}): Promise<T> {
  const res = await fetch(`${PORKBUN_BASE}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...auth, ...body }),
  });
  const data = await res.json();
  if (data.status !== "SUCCESS") throw new Error(`Porkbun API error: ${data.message}`);
  return data;
}

// List all domains
export const listDomains = () => porkbun<{ domains: any[] }>("/domain/listAll");

// Get DNS records for a domain
export const getDnsRecords = (domain: string) =>
  porkbun<{ records: any[] }>(`/dns/retrieve/${domain}`);

// Create a DNS record
export const createDnsRecord = (
  domain: string,
  type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS",
  name: string,
  content: string,
  ttl = "600"
) =>
  porkbun(`/dns/create/${domain}`, { type, name, content, ttl });

// Delete a DNS record
export const deleteDnsRecord = (domain: string, recordId: string) =>
  porkbun(`/dns/delete/${domain}/${recordId}`);

// Edit a DNS record
export const editDnsRecord = (
  domain: string,
  recordId: string,
  type: string,
  name: string,
  content: string
) => porkbun(`/dns/edit/${domain}/${recordId}`, { type, name, content });

Python Client Helper

import os, requests

PORKBUN_BASE = "https://api.porkbun.com/api/json/v3"
AUTH = {
    "apikey": os.environ["PORKBUN_API_KEY"],
    "secretapikey": os.environ["PORKBUN_SECRET_API_KEY"],
}

def porkbun(path: str, **kwargs) -> dict:
    res = requests.post(
        f"{PORKBUN_BASE}{path}",
        json={**AUTH, **kwargs},
    )
    data = res.json()
    if data.get("status") != "SUCCESS":
        raise Exception(f"Porkbun API error: {data.get('message')}")
    return data

# List domains
domains = porkbun("/domain/listAll")["domains"]

# Get DNS records
records = porkbun(f"/dns/retrieve/example.com")["records"]

# Create a DNS record
porkbun(
    "/dns/create/example.com",
    type="A",
    name="api",
    content="1.2.3.4",
    ttl="600",
)

# Create a TXT record (for domain verification)
porkbun(
    "/dns/create/example.com",
    type="TXT",
    name="",   # root domain
    content="v=spf1 include:mailgun.org ~all",
    ttl=600,   # integer seconds; min is account-set (typically 600), 0 = account minimum
)

Common API Endpoints

The auth fields merge into the JSON body. Build the body once with jq so the JSON is always valid (the old "$AUTH"' + {...}' string-concat trick emits a literal + and is broken — don't use it):

BASE="https://api.porkbun.com/api/json/v3"
# body <endpoint-json> -> merges auth + your fields into one valid JSON object
body() { jq -nc --arg k "$PORKBUN_API_KEY" --arg s "$PORKBUN_SECRET_API_KEY" \
  --argjson extra "${1:-{}}" '{apikey:$k, secretapikey:$s} + $extra'; }

# Ping / test connection
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" "$BASE/ping" | jq .status

# List all domains (paginated 1000 at a time via "start")
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" "$BASE/domain/listAll" | jq '.domains[].domain'

# Get DNS records for a domain (also: GET with X-API-Key headers). Note the new "cloudflare" field.
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" "$BASE/dns/retrieve/example.com" | jq '.records[] | {id,type,name,content}'

# Create DNS A record — ttl is an INTEGER now; min is account-set (typically 600), 0 = account minimum
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"type":"A","name":"subdomain","content":"1.2.3.4","ttl":600}')" \
  "$BASE/dns/create/example.com" | jq .

# Update-in-place by name+type (no need to retrieve→delete→create for existing records)
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"content":"5.6.7.8","ttl":600}')" \
  "$BASE/dns/editByNameType/example.com/A/subdomain" | jq .status

# Check domain availability + price (rate-limited: 1 / 10 s / account by default). Domain is in the PATH only.
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
  "$BASE/domain/checkDomain/my-new-domain.com" | jq '{avail:.response.avail, price:.response.price, limits}'

# Get SSL certificate bundle (Let's Encrypt; must be issued — status HAVECERT)
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body)" "$BASE/ssl/retrieve/example.com" | jq '{certificatechain,privatekey,publickey}'

# Set URL forwarding (wildcard is required; use redirectType for an exact 301/302/307/masked)
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"subdomain":"www","location":"https://example.com","type":"temporary","includePath":"yes","wildcard":"no"}')" \
  "$BASE/domain/addUrlForward/example.com" | jq .

Zero-setup shape discovery: every path is mirrored, no auth, under /mock — e.g. curl -s "$BASE/mock/domain/checkDomain/example.com" | jq returns a schema-accurate example response.


Domain pricing & lifecycle via API

This changed with v3.15 (2026). Porkbun now exposes the full domain lifecycle over the API — domain/create (register), domain/renew, and domain/transfer (inbound). The old "registration is dashboard-only" limitation is gone. Registration is a billable write paid from account credit, so it's gated and rate-limited on purpose:

domain/create/{domain} — register using account credit. Requirements: verified account email + phone, sufficient credit, agreeToTerms = "yes"/"1", and a cost (in pennies) that exactly matches the current price for the domain's minimum duration (get it from checkDomain first). The account must also have made at least one prior registration, and premium/aftermarket names can't be registered via API. Registrations are always for the registry-minimum term (usually 1 year); WHOIS privacy is auto-enabled where supported. Rate limits: 1 attempt / 10 s and 50 successes / 24 h (both per account, configurable per key). Always dryRun: true first to validate availability + price + funds without charging.

# 1) Confirm price (pennies) from checkDomain, then dry-run the registration
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"cost":973,"agreeToTerms":"yes","dryRun":true}')" \
  "$BASE/domain/create/codeamanilabs.io" | jq '{wouldSucceed, cost, costDisplay, sufficientFunds}'

# 2) Drop dryRun to actually register (charges account credit)
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"cost":973,"agreeToTerms":"yes"}')" \
  "$BASE/domain/create/codeamanilabs.io" | jq '{status, domain, cost, orderId, balance}'

For per-TLD eligibility fields (.us nexus, .ca legal type, etc.) call GET /domain/getRegistrationRequirements/{tld} first — it returns the create-request body as JSON Schema. The rest of the API-supported lifecycle:

Get the full price list

pricing/get returns registration, renewal, and transfer prices (USD strings) for every TLD. No auth required. Pass an optional tlds array to filter (POST); the GET form returns all TLDs:

BASE="https://api.porkbun.com/api/json/v3"

# Pricing for ALL TLDs (public)
curl -s "$BASE/pricing/get" | jq '.pricing.com, .pricing.org, .pricing.dev'

# Filter to specific TLDs
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"tlds":["com","org","dev"]}' "$BASE/pricing/get" | jq .pricing
{ "registration": "9.73", "renewal": "9.73", "transfer": "9.73", "specialType": null, "coupons": {} }

Check availability + price for one name

checkDomain/{domain} returns availability and price. It's the go-to before domain/create. Rate-limited to 1 check / 10 s / account by default (returns a limits object + ttlRemaining):

curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
  "$BASE/domain/checkDomain/codeamanilabs.io" | jq '.response'
# { "avail":"yes", "type":"registration", "price":"9.73", "regularPrice":"9.73",
#   "premium":"no", "minDuration":1, "additional":{ "renewal":{…}, "transfer":{…} } }

List your registered domains (with filters)

domain/listAll paginates 1000 at a time via the start offset (or fetch one with GET /domain/get/{domain}):

curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"start":"0","includeLabels":"yes"}')" \
  "$BASE/domain/listAll" | jq '.domains[] | {domain, status, expireDate, autoRenew}'

Get & update nameservers

Register (or hold) a domain, then point it at an external DNS provider (Cloudflare, Vercel, etc.):

# Get current authoritative nameservers
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
  "$BASE/domain/getNs/codeamanilabs.org" | jq '.ns'

# Replace nameservers (the ns array fully overwrites — list ALL of them)
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"ns":["maceio.ns.porkbun.com","fortaleza.ns.porkbun.com"]}')" \
  "$BASE/domain/updateNs/codeamanilabs.org" | jq '.status'

Glue records (vanity / child nameservers)

Needed only if you run your own nameservers on a subdomain of the registered domain:

# List existing glue records
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
  "$BASE/domain/getGlue/example.com" | jq .

# Create glue: ns1.example.com -> IPs (v4 and/or v6)
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"ips":["1.2.3.4","2606:4700::1"]}')" \
  "$BASE/domain/createGlue/example.com/ns1" | jq '.status'
# updateGlue/{domain}/{subdomain} and deleteGlue/{domain}/{subdomain} mirror this shape

DNSSEC records

DNSSEC DS records live under the /dns/ namespace, not /domain/:

# Get DNSSEC records
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
  "$BASE/dns/getDnssecRecords/example.com" | jq .

# Create a DS record
curl -s -X POST -H "Content-Type: application/json" \
  -d "$(body '{"keyTag":"64087","alg":"13","digestType":"2","digest":"<hash>"}')" \
  "$BASE/dns/createDnssecRecord/example.com" | jq '.status'

# Delete by key tag
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
  "$BASE/dns/deleteDnssecRecord/example.com/64087" | jq '.status'

Gotcha — checkDomain is rate-limited. Unlike DNS endpoints, domain/checkDomain hits Porkbun's upstream registry and is throttled — the default is 1 check per 10 seconds per account (configurable per key); the response carries a limits object and ttlRemaining so you can pace yourself. Don't loop it over a wordlist to brainstorm names. For bulk price comparisons use pricing/get once (it's the whole TLD table in a single call) and only call checkDomain for the handful of finalists.

flowchart TD
  A["pricing/get<br/>whole TLD price table"] --> B["Pick candidate names"]
  B --> C["checkDomain/name<br/>availability · price · rate-limited"]
  C --> D{"Available<br/>and priced ok?"}
  D -->|"no"| B
  D -->|"yes"| E["domain/create<br/>dryRun then register · via API"]
  E --> F["updateNs · DNS · DNSSEC<br/>all via API"]

Environment Variables

# Required (both are secrets — neither is publishable)
PORKBUN_API_KEY=pk1_...             # From porkbun.com/account/api (API key; pk1_sb_ = sandbox)
PORKBUN_SECRET_API_KEY=sk1_...      # From porkbun.com/account/api (Secret API key; sk1_sb_ = sandbox)

Store in .env and never commit to version control. Add to .gitignore:

.env
.env.local
*.env

Automation Workflows

You've got the API down — now let automation handle the repetitive parts. For a record that already exists, dns/editByNameType/{domain}/{type}/{subdomain} updates it in place (v3.15) — no retrieve→delete→create needed. When you can't assume the record exists (the general case the /dns slash command and the GitHub Action below handle), the safe flow is still retrieve → replace:

flowchart TD
  A["Retrieve existing records<br/>dns/retrieve/domain"] --> B{"Record<br/>already exists?"}
  B -->|"yes"| C["Delete old record<br/>dns/delete/domain/id"]
  B -->|"no"| D["Create record<br/>dns/create/domain"]
  C --> D
  D --> E["Retrieve again<br/>to verify new value"]
  E --> F["Report record id<br/>and new value"]

Claude Code Slash Command: Update DNS

.claude/commands/dns.md:

Update the DNS record for $ARGUMENTS.

Parse $ARGUMENTS as: "subdomain.domain.com TYPE value" (e.g., "api.example.com A 1.2.3.4")

Use Bash to call the Porkbun API:
1. First retrieve existing records to check if the record exists
2. If it exists, delete the old record, then create a new one
3. If it doesn't exist, create it directly
4. Verify the update by retrieving records again and confirming the new value

Report: what was changed, the record ID, and the new value.

Usage: /project:dns api.example.com A 1.2.3.4

Hook: Verify Domain After Deploy

.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/verify-dns.js"
          }
        ]
      }
    ]
  }
}

scripts/verify-dns.js:

const domain = process.env.DOMAIN_NAME;
if (!domain) process.exit(0);

const response = await fetch(`https://api.porkbun.com/api/json/v3/dns/retrieve/${domain}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    apikey: process.env.PORKBUN_API_KEY,
    secretapikey: process.env.PORKBUN_SECRET_API_KEY,
  }),
});

const data = await response.json();
if (data.status === "SUCCESS") {
  console.log(`DNS verified — ${data.records.length} records for ${domain}`);
} else {
  console.error("DNS verification failed:", data.message);
}

GitHub Actions: Auto-update DNS on Vercel Deploy

# .github/workflows/update-dns.yml
name: Update Porkbun DNS
on:
  workflow_dispatch:
    inputs:
      subdomain:
        description: Subdomain to update
        required: true
      ip:
        description: New IP address
        required: true

jobs:
  update-dns:
    runs-on: ubuntu-latest
    steps:
      - name: Update DNS A record
        env:
          PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }}
          PORKBUN_SECRET_API_KEY: ${{ secrets.PORKBUN_SECRET_API_KEY }}
        run: |
          # Get existing record ID
          records=$(curl -s -X POST https://api.porkbun.com/api/json/v3/dns/retrieve/${{ secrets.DOMAIN_NAME }} \
            -H "Content-Type: application/json" \
            -d "{\"apikey\":\"$PORKBUN_API_KEY\",\"secretapikey\":\"$PORKBUN_SECRET_API_KEY\"}")

          record_id=$(echo $records | jq -r ".records[] | select(.name==\"${{ inputs.subdomain }}\") | .id")

          if [ -n "$record_id" ]; then
            # Delete existing
            curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/delete/${{ secrets.DOMAIN_NAME }}/$record_id" \
              -H "Content-Type: application/json" \
              -d "{\"apikey\":\"$PORKBUN_API_KEY\",\"secretapikey\":\"$PORKBUN_SECRET_API_KEY\"}"
          fi

          # Create new — ttl is an integer; min is account-set (typically 600), 0 = account minimum
          curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/create/${{ secrets.DOMAIN_NAME }}" \
            -H "Content-Type: application/json" \
            -d "{\"apikey\":\"$PORKBUN_API_KEY\",\"secretapikey\":\"$PORKBUN_SECRET_API_KEY\",\"type\":\"A\",\"name\":\"${{ inputs.subdomain }}\",\"content\":\"${{ inputs.ip }}\",\"ttl\":600}"

Common Use Cases

Use Case Approach
Register a domain /domain/create/{domain} (dryRun first) — v3.15, no longer dashboard-only
Renew / transfer-in /domain/renew/{domain} · /domain/transfer/{domain}
Point subdomain to new IP Create A record, or dns/editByNameType to update in place
Domain verification (TXT) Create TXT record for email/GSC verification
SSL certificates ssl/retrieve/{domain} for the Let's Encrypt bundle (status HAVECERT)
URL forwarding /domain/addUrlForward/{domain} (list getUrlForwarding, remove deleteUrlForward/{id})
Check availability + price /domain/checkDomain/{domain}
DKIM/SPF for email Create TXT records via API
Give an agent least-privilege access Scope the key to specific domains / source-IP CIDR at porkbun.com/account/api

Troubleshooting

Errors now carry a machine-readable code (and often a next_action object) alongside the human-readable message — branch on code, not on message text.

Issue Fix
API key invalid Regenerate keys; ensure no trailing spaces when copying. Confirm you're not mixing a sandbox pk1_sb_ key with the production base URL.
Domain not found Domain must be in your account and have per-domain API Access toggled on (domain settings).
Registration rejected checkDomain first; cost (pennies) must exactly match; account needs verified email/phone, credit, ≥1 prior registration; premium names aren't API-registerable. Use dryRun to see why.
Rate limited (checkDomain / create) Respect limits/ttlRemaining in the response; default is 1 check or attempt / 10 s / account.
SSL retrieve fails Certificate must already be issued (status HAVECERT); wait for DNS propagation + issuance, then retry.
API access denied Enable API access at porkbun.com/account/api, and toggle per-domain API Access.
TTL rejected as too low ttl is an integer; minimum is account-set (typically 600). Send 0 to use the account minimum.

Official docs:

Progressive Web Apps Integration Guide

Technology: pwa · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/pwa

Insight:

A PWA is a normal Next.js app plus three pieces — a manifest.ts for install, a service worker for the cache, and a caching strategy per asset. The trade-off is a hand-written sw.js (simple, but no fingerprinted precache) versus Serwist (a Workbox fork that builds the precache at compile time). The wrinkle in 2026: Next 16 builds with Turbopack by default, so the webpack-based @serwist/next plugin now has a sibling — @serwist/turbopack — for Turbopack builds. For codeAmani's mobile-first 2G/3G market, installable + offline + a tiny critical bundle is the baseline, not polish — and this dashboard is the live reference implementation.

██████╗ ██╗    ██╗ █████╗
██╔══██╗██║    ██║██╔══██╗
██████╔╝██║ █╗ ██║███████║
██╔═══╝ ██║███╗██║██╔══██║
██║     ╚███╔███╔╝██║  ██║
╚═╝      ╚══╝╚══╝ ╚═╝  ╚═╝

Progressive Web Apps Integration Guide

Focus: make a Next.js web app installable and offline-capable — a web app manifest for the install, a service worker for the cache, and the right caching strategy per asset. On a 2G/3G phone in Nairobi this is the difference between a usable app and a spinner. This dashboard is already a PWA — the patterns below are running in this very repo.

Overview

A Progressive Web App is, in MDN's words, "an app that's built using web platform technologies, but that provides a user experience like that of a platform-specific app." One codebase ships to every device like a website, yet it can be installed to the home screen, launched full-screen, and keep working with no network. Three pieces make that happen:

  1. Web app manifest — a small JSON file that tells the browser the app's name, icons, colors and launch mode. Once it is present (plus HTTPS and a service worker), the browser treats the site as installable and offers "Add to Home Screen".
  2. Service worker — a script that runs in its own thread, outside any page, and sits "as middleware between your PWA and the servers it interacts with." It intercepts every network request in its scope and decides whether to answer from cache, from the network, or from both.
  3. A caching strategy — the policy the service worker applies per request: serve fast from cache, or fetch fresh from the network, or do both at once. Choosing the right one per asset type is the whole game.

For codeAmani, this is not a nice-to-have. Our users are mobile-first on Android over 2G/3G, where a cold network round-trip can take seconds and connections drop mid-session. An installable, offline-first app with a tiny critical bundle is a core requirement — and it is exactly why this learning dashboard ships app/manifest.ts, a service worker at public/sw.js, an app/offline/ fallback, and lib/pwa.ts.

Official Documentation

Source URL What it covers
MDN — Progressive Web Apps https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps What a PWA is, installability requirements, manifest members
web.dev — Service workers https://web.dev/learn/pwa/service-workers Lifecycle (install → activate → fetch), scope, request interception
Serwist — Next.js getting started https://serwist.pages.dev/docs/next/getting-started @serwist/next install + withSerwistInit + app/sw.ts
Next.js — manifest.ts https://nextjs.org/docs/app/api-reference/file-conventions/metadata/manifest App Router manifest generation via MetadataRoute.Manifest
Chrome — Caching strategies https://developer.chrome.com/docs/workbox/caching-strategies-overview cache-first / network-first / stale-while-revalidate, when to use each

1. The web app manifest (installability)

The manifest is what turns a page into an installable app. In Next.js App Router you generate it with a typed app/manifest.ts — Next serves it at /manifest.webmanifest and injects the <link rel="manifest"> for you. This is exactly what the dashboard ships (packages/dashboard/app/manifest.ts):

// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "codeAmani Tech-Stack",
    short_name: "Tech-Stack",
    description: "Interactive developer learning platform.",
    start_url: "/",
    scope: "/",
    display: "standalone",          // full-screen, no browser chrome
    orientation: "portrait",
    background_color: "#0f1115",     // splash screen color
    theme_color: "#0f1115",         // OS UI / status-bar color
    categories: ["education", "developer", "productivity"],
    icons: [
      { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
      { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
      // A *maskable* icon lets Android crop it to any shape without clipping the logo.
      { src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
    // Long-press the installed icon → jump straight to a route.
    shortcuts: [
      { name: "Search guides", short_name: "Search", url: "/search" },
      { name: "Browse categories", short_name: "Browse", url: "/browse" },
    ],
  };
}

Installability checklist (per MDN): served over HTTPS, a manifest with at minimum name/short_name, start_url, display, and icons (192px + 512px), plus a registered service worker. Meet those and Chrome/Edge fire beforeinstallprompt; iOS Safari requires the manual "Share → Add to Home Screen" flow (handled in components/pwa/install-prompt.tsx).

2. The service worker (lifecycle: install → activate → fetch)

A service worker is registered once from a page, then lives on its own. Its lifecycle has three events:

Scope is set by file location: a worker at /sw.js controls the whole origin; one at /app/sw.js only controls /app/…. Only one service worker is allowed per scope.

stateDiagram-v2
    [*] --> Registered: navigator.serviceWorker.register('/sw.js')
    Registered --> Installing: parse script
    Installing --> Installed: install event · precache shell
    Installed --> Activating: (skipWaiting skips the wait)
    Activating --> Activated: activate event · purge old caches · clientsClaim
    Activated --> Idle
    Idle --> Fetching: fetch event (any in-scope request)
    Fetching --> Idle: serve from cache / network
    Activated --> [*]: new SW found → cycle repeats

Register it from a client component (the dashboard's components/pwa/register-sw.tsx):

"use client";
import { useEffect } from "react";

export function RegisterServiceWorker() {
  useEffect(() => {
    if (!("serviceWorker" in navigator)) return;
    navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {
      // Non-fatal — the app still works without offline support.
    });
  }, []);
  return null;
}

3. Caching strategies (choose one per asset)

The strategy is the policy the fetch handler applies. Pick per asset type:

Strategy Behavior Reach for it on…
Cache-first Serve cache; only hit network on a miss, then cache it. Hash-versioned static assets — JS, CSS, fonts, images. "A speed boost for immutable assets."
Network-first Try network; fall back to cache when offline. HTML pages and API calls where freshness matters but offline access is valuable.
Stale-while-revalidate Serve cache immediately, refresh it from the network in the background. Non-critical, occasionally-updated content — avatars, thumbnails.
Cache-only Only the cache, never the network. Precached, versioned shell assets.
Network-only Always the network, never the cache. Truly dynamic markup / non-GET.
flowchart TD
    A[Request in scope] --> B{Versioned / immutable<br/>JS · CSS · fonts · icons?}
    B -- yes --> C[Cache-first]
    B -- no --> D{HTML page or API<br/>freshness matters?}
    D -- yes --> E[Network-first<br/>fallback to cache → /offline]
    D -- no --> F{Occasionally-updated<br/>avatar · thumbnail?}
    F -- yes --> G[Stale-while-revalidate]
    F -- no --> H[Network-only]

Precaching vs runtime caching

The dashboard's hand-written public/sw.js does both: it precaches the shell (["/", "/search", "/browse", "/more", "/offline"]) on install, uses network-first for navigations (falling back to cache and finally /offline), and cache-first for /_next/static/, icons and .webp/.png assets.

4. The Serwist toolchain (Next.js 16 / Turbopack)

Hand-writing sw.js is fine for a small, fixed shell, but it doesn't fingerprint precached assets, so a stale page can stick around after a deploy. The actively-maintained answer for the Next.js App Router is Serwist (currently v9.5.12; a 10.x preview is in the works), a fork of Google's Workbox — it builds the precache manifest at compile time and ships Workbox's caching strategies as defaultCache. next-pwa (shadowwalker) is unmaintained — do not reach for it on a new project.

Turbopack is the default builder in Next 16 (dev and build). The classic @serwist/next package is a webpack plugin, so it applies when you build with webpack. For Turbopack builds Serwist now ships a separate package — @serwist/turbopack — with a different wiring (see the Turbopack path below). Pick the one that matches how you build. The dashboard is on next@^16 (React 19).

Webpack path — @serwist/next

Install:

npm i @serwist/next && npm i -D serwist

Wrap next.config.mjs with withSerwistInit:

// next.config.mjs
import withSerwistInit from "@serwist/next";

const withSerwist = withSerwistInit({
  swSrc: "app/sw.ts",     // your worker source
  swDest: "public/sw.js", // compiled output (what gets registered)
});

export default withSerwist({
  // ...your Next.js config
});

Write the worker at app/sw.ts. self.__SW_MANIFEST is the injection point Serwist replaces with the build-time precache manifest:

// app/sw.ts
import { defaultCache } from "@serwist/next/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist } from "serwist";

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}
declare const self: ServiceWorkerGlobalScope;

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST, // build-time precache manifest
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: defaultCache,        // Workbox strategies, sensible defaults
});

serwist.addEventListeners();

defaultCache already applies the right strategy per asset type (cache-first for static, network-first for pages, stale-while-revalidate for the rest), so you rarely write raw fetch logic. Register the compiled public/sw.js exactly as in section 2.

Turbopack path — @serwist/turbopack

If you build with Turbopack (the Next 16 default), reach for @serwist/turbopack instead. The shape differs from the webpack plugin:

npm i -D @serwist/turbopack esbuild serwist
// next.config.mjs
import { withSerwist } from "@serwist/turbopack";

export default withSerwist({
  // ...your Next.js config
});

Rather than emitting a static file, it serves the compiled worker through a route handler at app/serwist/[path]/route.ts (built with createSerwistRoute from @serwist/turbopack, where you set swSrc, additionalPrecacheEntries, and useNativeEsbuild). The worker at app/sw.ts imports defaultCache from @serwist/turbopack/worker, and you register it with <SerwistProvider swUrl="…"> from @serwist/turbopack/react in your layout instead of the hand-rolled RegisterServiceWorker. Check the Turbopack getting-started for the current API — this path is newer than the webpack plugin and still moving.

5. Offline support

Offline is the payoff. With the shell precached and /offline as the navigation fallback, a user who loses signal mid-session still sees the app, not the browser's dinosaur. The dashboard's app/offline/page.tsx is a normal route that gets precached and served when a navigation fails. Add an additionalPrecacheEntries for it if it's not auto-detected:

withSerwistInit({
  swSrc: "app/sw.ts",
  swDest: "public/sw.js",
  additionalPrecacheEntries: [{ url: "/offline", revision: "v1" }],
});

Next 16 also ships an experimental useOffline hook (with a matching experimental.useOffline config flag) for connectivity-aware UI and automatic retries of failed navigations and Server Action requests — a lighter-weight complement to a full service-worker cache when you only need "detect offline, retry when back." It does not replace precaching; treat it as experimental.

Push notifications (Push API + web-push + Server Actions)

Push is the re-engagement lever, and Next's official PWA guide now spells out the full loop. It works across modern browsers, including iOS 16.4+ for a home-screen-installed PWA (installed, not in the Safari tab).

  1. Subscribe on the client. Register the worker, then subscribe through pushManager:
"use client";
import { subscribeUser } from "./actions";

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  const sub = await registration.pushManager.subscribe({
    userVisibleOnly: true,                                  // required on Chrome
    applicationServerKey: urlBase64ToUint8Array(
      process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,            // public VAPID key
    ),
  });
  await subscribeUser(JSON.parse(JSON.stringify(sub)));     // persist server-side
}
  1. Send from the server with the web-push library inside a Server Action (app/actions.ts) — never from the client, so the private VAPID key stays server-side:
"use server";
import webpush from "web-push";

webpush.setVapidDetails(
  "mailto:you@example.com",
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,                           // server-only secret
);

export async function sendNotification(sub: PushSubscription, message: string) {
  await webpush.sendNotification(
    sub,
    JSON.stringify({ title: "codeAmani", body: message, icon: "/icon.png" }),
  );
}
  1. Handle it in the worker — push shows the notification, notificationclick focuses the app:
self.addEventListener("push", (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, { body: data.body, icon: data.icon }),
  );
});
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow("/"));
});

Generate the VAPID pair once with npx web-push generate-vapid-keys, then set NEXT_PUBLIC_VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY (see ENV_MASTER.md). Ask for the permission grant contextually, never on first load — a cold prompt is the fastest way to get blocked forever.

Background Sync is the other resilience primitive: defer a failed POST (an M-Pesa-adjacent action, saving progress) until connectivity returns, and the browser replays it from a sync event. Invaluable on flaky 2G/3G. Both push and sync run inside the same service worker you already registered.

Testing push locally: service workers and push need a secure origin. Instead of ngrok, run next dev --experimental-https for a locally-trusted HTTPS dev server (localhost also counts as secure for SW registration). Verify the browser has notifications enabled.

codeAmani notes

Official docs:

Railway Integration Guide

Technology: railway · Category: hosting · Last reviewed: 2026-09-05

Source: https://tech-stack.codeamanilabs.org/guide/railway

Insight:

Railway runs always-on containers, not serverless functions — reach for it when a process must outlive a request: queue consumers, SKIP LOCKED pollers, WebSocket servers, long-lived M-Pesa reconciliation workers. Vercel stays the default for the Next.js front end; Railway hosts the worker beside it, with managed Postgres/Redis on the same private network.

██████╗  █████╗ ██╗██╗     ██╗    ██╗ █████╗ ██╗   ██╗
██╔══██╗██╔══██╗██║██║     ██║    ██║██╔══██╗╚██╗ ██╔╝
██████╔╝███████║██║██║     ██║ █╗ ██║███████║ ╚████╔╝
██╔══██╗██╔══██║██║██║     ██║███╗██║██╔══██║  ╚██╔╝
██║  ██║██║  ██║██║███████╗╚███╔███╔╝██║  ██║   ██║
╚═╝  ╚═╝╚═╝  ╚═╝╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝  ╚═╝   ╚═╝

Railway Integration Guide

Focus — Deploying long-running services, workers, cron jobs and managed databases on Railway from Claude Code: CLI, config-as-code, the public GraphQL API, and the private-network topology that keeps egress costs at zero.

Overview

Railway is a container hosting platform. You point it at a repo, it builds an OCI image (via Railpack, the successor to Nixpacks) and runs it as a long-lived process with a public HTTPS domain, TLS, and health-checked zero-downtime deploys.

The distinction that matters when choosing it:

Shape Platform Why
Next.js app, request/response Vercel Serverless is the right fit; keep the default
Static site + edge functions Netlify / Cloudflare Edge-first
Process that must outlive a request Railway Queue consumers, pollers, WebSocket servers, schedulers
Managed Postgres + Redis on one private network Railway One project, one private network, no egress fees between services

A Railway project contains services (each a deployed container) across environments (production, staging, PR environments). Services in the same project and environment reach each other over a private IPv6 network — traffic there is free and never leaves Railway.

Official Documentation

Topic URL
Docs home https://docs.railway.com
Config as code (railway.json / .toml) https://docs.railway.com/reference/config-as-code
Public GraphQL API https://docs.railway.com/reference/public-api
CLI reference https://docs.railway.com/reference/cli-api
Private networking https://docs.railway.com/reference/private-networking
Variables & reference variables https://docs.railway.com/guides/variables
Cron jobs https://docs.railway.com/guides/cron-jobs
Pricing & plan limits https://docs.railway.com/reference/pricing/plans
JSON schema (editor autocomplete) https://railway.com/railway.schema.json

CLI setup

# Install (v5.49.2 at time of review)
npm install -g @railway/cli

railway login            # opens a browser; use `railway login --browserless` over SSH
railway init             # create a new project from the current directory
railway link             # or: attach this directory to an existing project

railway up               # build + deploy, streaming logs
railway up -d            # detached — don't stream
railway up --ci          # CI mode: no interactive prompts
railway up --service my-api --environment staging

Useful day-to-day commands:

railway add                        # add a service or a database (Postgres, Redis, MySQL, Mongo)
railway variables                  # list variables for the linked service
railway variables set KEY=value    # set one
railway run -- npm run dev         # run locally WITH the remote environment's variables injected
railway logs                       # tail deploy/runtime logs
railway open                       # open the project dashboard

railway run is the one to remember: it injects the live environment's variables into a local process, so local dev hits the same database and secrets as the deployed service without ever copying them into a .env file.

Config as code

Commit a railway.json (or railway.toml) next to your service. It overrides dashboard settings, so infrastructure changes ship in the same PR as the code.

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "RAILPACK"
  },
  "deploy": {
    "startCommand": "node dist/worker.js",
    "preDeployCommand": "npm run db:migrate",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 300,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

The TOML form is equivalent:

[build]
builder = "railpack"
buildCommand = "npm run build"

[deploy]
preDeployCommand = ["npm run db:migrate"]
startCommand = "node dist/worker.js"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "on_failure"

Key fields:

railway.toml does not support volume-mount configuration — use railway.json or the dashboard for volumes.

The PORT contract

Railway injects a PORT environment variable and expects your server to bind it on 0.0.0.0. Hardcoding a port is the single most common cause of a service that builds fine and then fails its health check.

const port = Number(process.env.PORT) || 3000;
app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));

If your app cannot listen on PORT (for example when using target ports), set a PORT variable explicitly so Railway probes the right one.

Variables, references and private networking

Railway variables are per-service, per-environment. Reference variables interpolate one service's value into another's, so a connection string is never copy-pasted:

# In the app service, referencing the Postgres service in the same project:
DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}

# Reference another service's private address:
API_URL=http://${{api.RAILWAY_PRIVATE_DOMAIN}}:3000

Prefer the private form. Every service gets a RAILWAY_PRIVATE_DOMAIN resolvable only inside the project's IPv6 network:

Bind private listeners to IPv6 (::) — a server listening only on 0.0.0.0 is unreachable over the private network.

Railway also injects RAILWAY_ENVIRONMENT, RAILWAY_SERVICE_NAME, RAILWAY_PUBLIC_DOMAIN and RAILWAY_GIT_COMMIT_SHA — useful for tagging Sentry releases and structured logs.

Public GraphQL API

One endpoint: POST https://backboard.railway.com/graphql/v2.

Token type Header Scope
Account / Workspace / OAuth Authorization: Bearer <TOKEN> Account or workspace-wide
Project token Project-Access-Token: <TOKEN> A single environment in one project
curl --request POST \
  --url https://backboard.railway.com/graphql/v2 \
  --header "Project-Access-Token: $RAILWAY_PROJECT_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"query":"query { projectToken { projectId environmentId } }"}'

From TypeScript — note fetch, never a shell-based HTTP call:

const RAILWAY_API = "https://backboard.railway.com/graphql/v2";

export async function railwayQuery<T>(
  query: string,
  variables: Record<string, unknown> = {},
): Promise<T> {
  const token = process.env.RAILWAY_API_TOKEN;
  if (!token) throw new Error("RAILWAY_API_TOKEN is not set");

  const res = await fetch(RAILWAY_API, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ query, variables }),
  });

  if (res.status === 429) {
    const retryAfter = res.headers.get("Retry-After") ?? "60";
    throw new Error(`Railway rate limit hit; retry after ${retryAfter}s`);
  }
  if (!res.ok) throw new Error(`Railway API ${res.status}: ${await res.text()}`);

  const body = (await res.json()) as { data?: T; errors?: { message: string }[] };
  if (body.errors?.length) throw new Error(body.errors.map((e) => e.message).join("; "));
  if (!body.data) throw new Error("Railway API returned no data");
  return body.data;
}

Rate limits (responses carry X-RateLimit-Limit, -Remaining, -Reset):

Plan Per hour Per second
Free 100 —
Hobby 1,000 10
Pro 10,000 50

Cron jobs

Set a Cron Schedule on a service and Railway runs its start command on that schedule. The rules are strict and worth internalising:

*/15 * * * *   # every 15 minutes
0 3 * * *      # 03:00 UTC daily  (= 06:00 EAT; Kenya is UTC+3 year-round)

Because the schedule is UTC and East Africa Time has no DST, an EAT-local job is a fixed −3h offset — 0 3 * * * is reliably 6am in Nairobi.

CI/CD with GitHub Actions

Deploy on green tests using a project token stored as a repository secret:

# .github/workflows/railway-deploy.yml
name: Deploy to Railway
on:
  push:
    branches: [master]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test
      - name: Deploy
        run: |
          npm install -g @railway/cli
          railway up --ci --service "$RAILWAY_SERVICE"
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
          RAILWAY_SERVICE: api

RAILWAY_TOKEN in the environment authenticates the CLI non-interactively — no railway login step. Scope it to a project token so a leaked CI secret can touch exactly one environment.

Pricing model

Usage-based, billed per minute of actual consumption:

Resource Rate
Memory $10 / GB / month ($0.000231 / GB / min)
vCPU $20 / vCPU / month ($0.000463 / vCPU / min)
Network egress $0.05 / GB
Plan Subscription Included usage Per-service ceiling
Hobby $5/mo $5 48 GB RAM, 48 vCPU, 6 replicas
Pro $20/mo $20 1 TB RAM, 1,000 vCPU, 42 replicas
Enterprise Custom Custom 2.4 TB RAM, 2,400 vCPU, 50 replicas

The subscription includes an equal amount of usage, so a small always-on worker on Hobby is often fully covered. Because billing tracks provisioned resources over time, an idle service still costs memory — size containers deliberately rather than leaving defaults.

codeAmani notes

Security

Where Railway fits our stack

Vercel remains the default for Next.js front ends. Railway earns its place for the part Vercel structurally cannot host — a process that outlives a request:

Kenya-targeted projects

Provenance

A Railway deploy is a deploy, not a downloadable artifact — per our SLSA policy that means no provenance target. Pin the GitHub Actions used in the deploy workflow to commit SHAs and document the build; there is nothing for slsa-verifier to verify. If a project additionally ships a container image or release tarball, that artifact takes Build L3 on its own track — see supply-chain/CLAUDE_CODE_INTEGRATION.md.

Troubleshooting

Symptom Cause Fix
Build succeeds, deploy never activates Health check never returns 200 Bind process.env.PORT on 0.0.0.0; confirm healthcheckPath exists and is unauthenticated
service unavailable on health check Hardcoded port, or target ports in use Bind PORT, or set a PORT variable telling Railway which port to probe
Service unreachable over private network Listening on 0.0.0.0 only Bind IPv6 (::) for private-network traffic
Cron job never fires again Previous run never exited Close DB pools and exit; overlapping runs are skipped, not queued
Cron fires less often than expected Interval below the floor Minimum is 5 minutes
Unexpected egress charges Services talking over public domains Switch to RAILWAY_PRIVATE_DOMAIN / reference variables
429 from the GraphQL API Plan rate limit Honour Retry-After; batch queries
Migrations race the new deploy Migration in startCommand Move it to deploy.preDeployCommand

Official docs:

Raspberry Pi — Novice-to-Pro Developer Guide

Technology: raspberry-pi · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/raspberry-pi

Insight:

A Raspberry Pi is a full Linux computer the size of a credit card with a 40-pin GPIO header bolted to the side — so the same board both serves web apps and toggles real-world pins. Two rules decide everything: (1) the GPIO pins are 3.3V only — feed them 5V and you fry the SoC, so level-shift or use a Pico for 5V sensors; (2) gpiozero speaks BCM numbering, never the physical pin position (BCM17 ≠ physical pin 17). Flash with Raspberry Pi Imager (set SSH + Wi-Fi + hostname before first boot), ssh in headless, and you have an always-on ARM64 Linux box for ~$15–80 that sips power — ideal for an edge node on an East-African solar/battery setup.

██████╗  █████╗ ███████╗██████╗ ██████╗ ███████╗██████╗ ██████╗ ██╗   ██╗    ██████╗ ██╗
██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗╚██╗ ██╔╝    ██╔══██╗██║
██████╔╝███████║███████╗██████╔╝██████╔╝█████╗  ██████╔╝██████╔╝ ╚████╔╝     ██████╔╝██║
██╔══██╗██╔══██║╚════██║██╔═══╝ ██╔══██╗██╔══╝  ██╔══██╗██╔══██╗  ╚██╔╝      ██╔═══╝ ██║
██║  ██║██║  ██║███████║██║     ██████╔╝███████╗██║  ██║██║  ██║   ██║       ██║     ██║
╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝╚═╝     ╚═════╝ ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝   ╚═╝       ╚═╝     ╚═╝

Raspberry Pi — Novice-to-Pro Developer Guide

Focus: A complete path from "I just bought a Pi" to shipping production edge workloads — hardware models, headless OS setup, the 40-pin GPIO header and its 3.3V rule, the Python physical-computing stack (gpiozero, picamera2, lgpio), build ideas, experiments to run with Claude Code, and how the Pi compares to Arduino, ESP32, Jetson, and the Pi's own Pico microcontroller. Grounded in raspberrypi.com/documentation + gpiozero.readthedocs.io; reviewed 2026-08-23.

The Raspberry Pi is the rare device that is approachable on day one and still in your rack five years later. The trick is to learn it in the right order: get a headless Linux box running first (no monitor, no keyboard), then reach for the soldering iron. The interactive learn module above this page is a live 40-pin GPIO pinout explorer — start there to build intuition for the header, then use this reference.

Table of Contents

  1. What a Raspberry Pi actually is
  2. Pick the right board
  3. Headless OS setup (the right way)
  4. First-boot configuration
  5. The 40-pin GPIO header
  6. Physical computing with gpiozero
  7. The camera & other interfaces (I2C/SPI/UART)
  8. Hardware components shopping list
  9. Build ideas — novice to pro
  10. Experiments with Claude Code
  11. Raspberry Pi Pico (the microcontroller)
  12. Comparable tech stacks
  13. codeAmani notes

Official Documentation

Resource URL
Documentation hub https://www.raspberrypi.com/documentation/
Getting started https://www.raspberrypi.com/documentation/computers/getting-started.html
Computers / hardware reference https://www.raspberrypi.com/documentation/computers/raspberry-pi.html
GPIO & the 40-pin header https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#gpio
gpiozero (Python) https://gpiozero.readthedocs.io/en/stable/
picamera2 manual https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf
Datasheets https://datasheets.raspberrypi.com/
Interactive pinout (community) https://pinout.xyz/

1. What a Raspberry Pi actually is

A single-board computer (SBC): a complete ARM-based Linux computer — CPU, RAM, USB, HDMI, networking, storage (microSD) — on one board, with a 40-pin GPIO header for talking to electronics. That dual nature is the whole point:

flowchart TB
  subgraph PI["Raspberry Pi — one board, two faces"]
    subgraph LINUX["Linux computer"]
      CPU["ARM64 SoC + RAM"]
      NET["Wi-Fi / Ethernet"]
      SD["microSD: OS + storage"]
      USB["USB / HDMI"]
    end
    subgraph PHYS["Physical-computing face"]
      GPIO["40-pin GPIO header (3.3V)"]
      CSI["CSI: camera"]
      I2CSPI["I2C · SPI · UART buses"]
    end
  end
  LINUX -->|"same OS drives both"| PHYS
  WORLD["Sensors · motors · LEDs · displays"] --- GPIO
  CLOUD["Your web app · MQTT · cloud"] --- NET
  class GPIO pulse;
  class CPU pulse;

Why it matters: because it's real Linux, everything you already know — ssh, systemd, Docker, Python, Node, cron, nginx — works unchanged. And because it has GPIO, the same box that runs a Flask app can also read a temperature sensor or drive a relay. It is the cheapest honest "computer + I/O" you can buy.

Microcontroller vs SBC: A Pi runs a full OS and is great at software (servers, ML, networking). A microcontroller (Arduino, ESP32, the Pi Pico) runs one program on bare metal with precise real-time timing and microamp sleep. Many real projects pair them: Pi for brains + network, Pico/Arduino for the twitchy real-time pins. See §11 and §12.


2. Pick the right board

Don't overbuy. Match the board to the job:

Board RAM Best for Rough power Notes
Pi Pico / Pico 2 / Pico 2 W — (RP2040/RP2350 MCU) Real-time sensors, 5V tolerant-ish I/O, battery µA–mA Not Linux — a microcontroller (§11)
Pi Zero 2 W 512 MB Tiny always-on jobs, wearables, cameras ~0.4–1 W idle Quad-core, Wi-Fi/BT; header often unpopulated (solder it)
Pi 4 Model B 1–8 GB Home server, NAS, light desktop ~3–6 W USB 3, dual HDMI, mature
Pi 5 2–16 GB Desktop replacement, ML inference, fast I/O ~5–12 W BCM2712, PCIe lane, much faster; needs active cooling
Pi 500 / 500+ 8 / 16 GB Keyboard-in-a-box desktop (Pi 5-class) ~5–12 W BCM2712; 500+ adds mechanical keys + built-in 256 GB M.2 SSD
Compute Module 4 / 5 up to 8 / 16 GB Embedding in your own carrier board varies CM5 is Pi 5-class (BCM2712); for products, not breadboards
flowchart LR
  Q1{"Need a full Linux OS?"}
  Q1 -->|No, just precise pins / battery| PICO["Pico 2 / ESP32"]
  Q1 -->|Yes| Q2{"Tiny & low-power, or muscle?"}
  Q2 -->|Tiny always-on| ZERO["Pi Zero 2 W"]
  Q2 -->|Server / ML / desktop| Q3{"Budget vs speed?"}
  Q3 -->|Proven & cheap| PI4["Pi 4 (4–8 GB)"]
  Q3 -->|Fastest, PCIe| PI5["Pi 5 (8–16 GB)"]

Also buy (these are not optional): a quality USB-C/micro-USB power supply (under-volting causes mysterious crashes — use the official PSU or a known-good 5V/3A+), a decent A2-rated microSD card (16 GB+; cheap cards corrupt), and for the Pi 5 active cooling. For reliability, an NVMe/USB-SSD boot beats microSD.


3. Headless OS setup (the right way)

You do not need a monitor or keyboard. The professional workflow is headless from minute one.

1. Flash with Raspberry Pi Imager. Download from raspberrypi.com/software, pick your board, choose Raspberry Pi OS (Lite = no desktop, perfect for servers; full = desktop). Then — and this is the step beginners skip — click the ⚙ / "Edit settings" (OS customisation) button before writing:

flowchart LR
  A["Raspberry Pi Imager"] e1@--> B["Choose OS + storage"]
  B e2@--> C["⚙ Edit settings:<br/>hostname · SSH · Wi-Fi · locale"]
  C e3@--> D["Write + verify"]
  D e4@--> E["Insert SD, power on"]
  E e5@--> F["ssh user@amani-pi.local"]
  e1@{ animate: true }
  e2@{ animate: true }
  e3@{ animate: true }
  e4@{ animate: true }
  e5@{ animate: true }
  class C glow;

2. Boot and SSH in — no screen required:

# From your laptop (mDNS resolves <hostname>.local on the same network)
ssh amani@amani-pi.local
# or by IP if .local doesn't resolve:
ssh amani@192.168.1.42

# First things first
sudo apt update && sudo apt full-upgrade -y
sudo reboot

If *.local doesn't resolve (some Windows/Android setups), find the IP from your router's DHCP table or ping amani-pi.local. On Windows, install Bonjour/iTunes or just use the IP.


4. First-boot configuration

raspi-config is the official text-UI for system settings; everything in it is also scriptable:

sudo raspi-config            # interactive: interfaces, locale, hostname, boot
sudo raspi-config nonint do_i2c 0    # enable I2C non-interactively (0 = enable)
sudo raspi-config nonint do_spi 0    # enable SPI
sudo raspi-config nonint do_ssh 0    # ensure SSH on

Interfaces (I2C, SPI, the camera, UART) can also be toggled directly in /boot/firmware/config.txt via device-tree params — useful in provisioning scripts and Ansible:

# /boot/firmware/config.txt  — enable buses at boot
dtparam=i2c_arm=on
dtparam=spi=on
dtparam=audio=on
# camera autodetect is on by default on current Pi OS:
camera_auto_detect=1
# Sanity checks after enabling
ls /dev/i2c-*          # I2C bus appears
ls /dev/spidev*        # SPI device appears
i2cdetect -y 1         # scan the I2C bus for device addresses (sudo apt install i2c-tools)
vcgencmd measure_temp  # SoC temperature — watch for throttling

5. The 40-pin GPIO header

Every current Pi (and the Zero/Pico, sometimes unpopulated) exposes a 40-pin header on a 0.1in (2.54 mm) pitch. The single rule that saves your board:

⚠️ GPIO is 3.3V logic, not 5V. The pins source/sink 3.3V and are not 5V-tolerant — putting 5V on a GPIO input can permanently damage the SoC. There are two 5V pins (for powering peripherals) and several 3V3 and GND pins, but every signal pin is 3.3V. Use a level shifter for 5V sensors, or hang them off a Pico instead.

The header is a fixed standard across boards. The layout (physical pin → function):

Pin Function Pin Function
1 3V3 power 2 5V power
3 GPIO2 (I2C SDA) 4 5V power
5 GPIO3 (I2C SCL) 6 GND
7 GPIO4 (GPCLK0) 8 GPIO14 (UART TXD)
9 GND 10 GPIO15 (UART RXD)
11 GPIO17 12 GPIO18 (PCM/PWM)
13 GPIO27 14 GND
15 GPIO22 16 GPIO23
17 3V3 power 18 GPIO24
19 GPIO10 (SPI MOSI) 20 GND
21 GPIO9 (SPI MISO) 22 GPIO25
23 GPIO11 (SPI SCLK) 24 GPIO8 (SPI CE0)
25 GND 26 GPIO7 (SPI CE1)
27 GPIO0 (ID EEPROM) 28 GPIO1 (ID EEPROM)
29 GPIO5 30 GND
31 GPIO6 32 GPIO12 (PWM)
33 GPIO13 (PWM) 34 GND
35 GPIO19 (PCM/SPI1) 36 GPIO16
37 GPIO26 38 GPIO20 (PCM/SPI1)
39 GND 40 GPIO21 (PCM/SPI1)

BCM vs physical numbering — the #1 beginner trap. Software (gpiozero, RPi.GPIO) addresses pins by Broadcom (BCM) GPIO number, not by physical position. LED(17) means BCM17, which sits at physical pin 11 — not physical pin 17 (that's a 3V3 power pin). When in doubt, run pinout on the Pi:

pinout       # ASCII diagram of YOUR board's header (ships with gpiozero)

6. Physical computing with gpiozero

gpiozero is the official, beginner-friendly Python library — it wraps pins as devices (LED, Button, Servo, DistanceSensor) so you write intent, not register pokes. It's pre-installed on Raspberry Pi OS; otherwise:

sudo apt install python3-gpiozero      # recommended (also installs the `pinout` tool)
# or in a venv:
pip install gpiozero rpi-lgpio          # rpi-lgpio = modern lgpio backend for the Pi 5 (RP1 I/O)

Blink an LED (LED on BCM17 = physical pin 11, through a ~330Ω resistor to GND):

from gpiozero import LED
from time import sleep
from signal import pause

led = LED(17)            # BCM17 — NOT physical pin 17
while True:
    led.toggle()
    sleep(0.5)

Button toggles LED — event-driven, no polling loop:

from gpiozero import LED, Button
from signal import pause

led = LED(17)
button = Button(2)       # BCM2, with internal pull-up by default

button.when_pressed = led.on
button.when_released = led.off
pause()                  # sleep forever, let callbacks fire

PWM for brightness / motor speed:

from gpiozero import PWMLED
from time import sleep

led = PWMLED(17)
while True:
    led.value = 0.1      # 10% duty cycle
    sleep(1)
    led.pulse()          # smooth fade in/out
    sleep(3)

gpiozero uses BCM numbering and it is not configurable — but you can pass alternate notations that all resolve to BCM: LED(17), LED("GPIO17"), LED("BOARD11") (physical), or LED("J8:11") (header:pin). For lower-level work, RPi.GPIO (legacy) / rpi-lgpio (its drop-in successor on Pi 5) and pigpio (hardware-timed PWM, remote GPIO over the network) are the steps down toward the metal.

flowchart TB
  APP["Your Python script"] --> GZ["gpiozero (device abstractions)"]
  GZ --> BK{"Pin backend"}
  BK --> LG["lgpio / rpi-lgpio (Pi 5, current Pi OS)"]
  BK --> PIG["pigpio (HW-timed PWM, remote GPIO)"]
  LG --> HW["40-pin header"]
  PIG --> HW

7. The camera & other interfaces (I2C/SPI/UART)

Camera (CSI ribbon → picamera2). Modern Pi OS autodetects official camera modules. picamera2 is the supported Python API (the old picamera is deprecated):

sudo apt install -y python3-picamera2                  # full (with preview GUI deps)
sudo apt install -y python3-picamera2 --no-install-recommends   # Lite OS, no GUI
from picamera2 import Picamera2
from time import sleep

picam2 = Picamera2()
picam2.start()
sleep(2)                       # let auto-exposure settle
picam2.capture_file("shot.jpg")
picam2.stop()
rpicam-still -o test.jpg       # CLI capture (formerly libcamera-still)
rpicam-hello -t 5000           # 5s preview to confirm the camera is detected

The three buses, in one breath:

Bus Pins (BCM) Use it for Enable
I2C SDA=2, SCL=3 Many low-speed sensors/displays sharing 2 wires by address dtparam=i2c_arm=on
SPI MOSI=10, MISO=9, SCLK=11, CE0=8 Fast displays, ADCs, SD/flash dtparam=spi=on
UART TXD=14, RXD=15 Serial to a Pico/Arduino/GPS/modem console off, enable_uart=1
i2cdetect -y 1        # find I2C device addresses (e.g. 0x3c for an OLED, 0x76 for BME280)

8. Hardware components shopping list

A starter kit that covers 90% of beginner projects:

The cardinal hardware rules: common ground between Pi and any external supply; never source motor/relay current from a GPIO pin; level-shift anything 5V; and double-check polarity before powering on.


9. Build ideas — novice to pro

flowchart LR
  N["NOVICE<br/>blink · button · buzzer<br/>web 'hello' on the LAN"]
  --> I["INTERMEDIATE<br/>weather station (BME280→DB)<br/>Pi-hole · home VPN · NAS<br/>time-lapse camera"]
  --> A["ADVANCED<br/>MQTT sensor mesh<br/>Kiosk / digital signage<br/>Retro game console"]
  --> P["PRO<br/>Edge ML (Hailo/Coral)<br/>K3s cluster of Pis<br/>Product on a Compute Module"]

10. Experiments with Claude Code

Claude Code runs on the Pi (it's just Linux + Node) or drives it remotely over SSH from your laptop. Both unlock fast hardware iteration: describe the circuit and the behaviour, let Claude write the gpiozero script, run it, read the output, and iterate.

# On the Pi (ARM64 Linux): install Node, then Claude Code
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
npm install -g @anthropic-ai/claude-code     # see the claude-api guide for current install
cd ~/projects/pi-lab && claude

Experiments that play to Claude Code's strengths:

  1. Conversational circuit bring-up. "I wired a BME280 to I2C and an SSD1306 OLED — read temp/humidity every 10s and show it on the display." Claude writes it; i2cdetect -y 1 confirms addresses; you run and refine.
  2. Hardware-in-the-loop TDD. Have Claude write a fake/mock pin backend (gpiozero supports a mock pin factory, GPIOZERO_PIN_FACTORY=mock) so logic is unit-tested on your laptop, then deployed to real pins on the Pi.
  3. Sensor → cloud pipeline. "Publish each reading to MQTT and also POST to a Next.js API route." Pairs with the webhooks and supabase guides.
  4. Local LLM on the edge. Run a small quantised model (ollama / llama.cpp) on a Pi 5 and have Claude Code build the glue: a voice-or-text assistant that works offline — relevant where connectivity is intermittent.
  5. Vision on the edge. Wire the camera + a Hailo/Coral accelerator; Claude scaffolds a picamera2 capture loop feeding an object detector, writing events to a DB.
  6. Remote GPIO from your dev box. pigpio exposes GPIO over the network — Claude Code on your laptop can prototype against the Pi's pins without copying files each iteration.

Run Claude Code over SSH for the tightest loop: edit on the laptop, execute on the Pi, watch real sensor output stream back. Pair with chrome-devtools to verify any web UI the Pi serves.


11. Raspberry Pi Pico (the microcontroller)

The Pico is a different animal: a microcontroller board built on the in-house RP2040 (Pico/Pico W) or RP2350 (Pico 2 / Pico 2 W) chip — not a Linux computer. The RP2350 keeps the dual-core, dual-PIO design but adds switchable Arm Cortex-M33 or RISC-V (Hazard3) cores, more SRAM, and secure/signed boot. No OS, no SD card; you flash one program that runs on bare metal. It shines where the Pi is weak: precise real-time timing, microamp sleep, true analog inputs (ADC), and being cheap enough to scatter.

flowchart LR
  subgraph SBC["Raspberry Pi (SBC)"]
    OS["Full Linux · network · ML · servers"]
  end
  subgraph MCU["Raspberry Pi Pico (MCU)"]
    BM["Bare-metal · real-time pins · ADC · µA sleep"]
  end
  MCU -->|"UART / I2C / USB"| SBC
  SBC -->|"brains + internet"| CLOUD["Cloud / dashboard"]

Two ways to program it:

# MicroPython (drag-and-drop firmware, then this is main.py) — blink the onboard LED
from machine import Pin
from time import sleep
led = Pin("LED", Pin.OUT)
while True:
    led.toggle()
    sleep(0.5)
// C/C++ with the Pico SDK — pico-examples style
#include "pico/stdlib.h"
int main() {
    const uint LED = PICO_DEFAULT_LED_PIN;
    gpio_init(LED); gpio_set_dir(LED, GPIO_OUT);
    while (true) { gpio_put(LED, 1); sleep_ms(250); gpio_put(LED, 0); sleep_ms(250); }
}

Pico W (RP2040) and Pico 2 W (RP2350) add 2.4 GHz Wi-Fi + Bluetooth, so they can publish to MQTT on their own. The classic architecture: Picos at the edge (sensors, real-time control) talking to a Pi hub (network, storage, dashboard).


12. Comparable tech stacks

What else lives in this space, and when to pick it instead:

Platform Type Pick it when
Arduino (Uno/Nano) MCU Dead-simple 5V I/O, huge tutorial base, no networking needed
ESP32 / ESP8266 MCU + Wi-Fi/BT Cheap connected sensors; Wi-Fi built in; battery IoT (the budget IoT king)
Raspberry Pi Pico / Pico 2 / Pico 2 W MCU (RP2040/RP2350) Real-time control, dual-core PIO, MicroPython/C, very cheap (Pico 2 W adds Wi-Fi)
NVIDIA Jetson (Nano/Orin) SBC + GPU On-device deep-learning / computer vision that a Pi can't keep up with
Google Coral Edge TPU (USB/dev board) Fast, low-power ML inference accelerator (add to a Pi, or standalone)
BeagleBone Black SBC Hard real-time via PRUs, lots of GPIO, industrial I/O
Orange Pi / Radxa Rock / Banana Pi SBC Pi-shaped boards, often more specs per dollar; software/community less polished
LattePanda / x86 mini-PCs SBC (x86) You need x86 + Windows compatibility, not ARM
flowchart TB
  Q{"What do you need?"}
  Q -->|"Heavy ML / vision"| JET["Jetson · or Pi + Coral/Hailo"]
  Q -->|"Cheap Wi-Fi sensor"| ESP["ESP32"]
  Q -->|"Real-time, no OS"| PICO["Pico 2 / Arduino"]
  Q -->|"Linux box + GPIO<br/>(the all-rounder)"| RPI["Raspberry Pi"]
  Q -->|"Hard real-time + Linux"| BBB["BeagleBone (PRUs)"]

The honest summary: the Pi wins on ecosystem — documentation, community answers, library support, and "it just works" software. Rivals win on specific axes (price-per-spec, GPU, battery life, real-time determinism). Most serious builds are hybrids: a Pi for brains + a Pico/ESP32 for the real-time edge.


13. codeAmani notes

Official docs:

Reddit Integration Guide

Technology: reddit · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/reddit

Insight:

Reddit is a social-listening + community-engagement channel, not a payment or auth rail. The core flow is a plain OAuth2 REST API (no SDK required) — the real constraints are operational and cultural: a mandatory descriptive User-Agent, a 100 QPM per-OAuth-client budget exposed via X-Ratelimit-* headers, and oauth.reddit.com as the base for authed calls. Since the 2023 paid-API shift the free tier is non-commercial only (commercial use needs an approved contract), so treat Reddit as a research/engagement channel, not bulk data. For codeAmani the goal is value-first participation to learn what US users actually need — never astroturf: sockpuppets, vote manipulation, and spammy self-promotion are bannable and burn the codeAmani-Labs account.

██████╗ ███████╗██████╗ ██████╗ ██╗████████╗
██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝
██████╔╝█████╗  ██║  ██║██║  ██║██║   ██║   
██╔══██╗██╔══╝  ██║  ██║██║  ██║██║   ██║   
██║  ██║███████╗██████╔╝██████╔╝██║   ██║   
╚═╝  ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚═╝   ╚═╝   

Reddit Integration Guide

Focus: Use the Reddit Data API as a market-research and community-engagement channel — listen to what users in our target subreddits actually ask for, surface threads codeAmani can genuinely answer, and grow the codeAmani-Labs account as a trusted voice (not a billboard).

Overview

Reddit is a forum-of-forums: thousands of topic communities ("subreddits") where people ask real questions and complain about real problems in their own words. That makes it one of the highest-signal voice-of-customer sources on the open web — and a place to build reputation by being helpful before being promotional.

The Reddit Data API is a JSON/REST API authenticated with OAuth2. There is no required SDK — every endpoint is reachable with fetch — but snoowrap (Node) and praw (Python) wrap auth, rate limiting, and pagination for you.

The codeAmani loop is listen → analyse → engage: pull threads, cluster them with Claude into themes/pain-points that inform the roadmap, then reply with genuine value (and only sometimes a soft, rule-compliant mention).

flowchart LR
  A["Register app<br/>reddit.com/prefs/apps"] -->|"client_id + secret"| B["OAuth2 token<br/>/api/v1/access_token"]
  B -->|"bearer token"| C["oauth.reddit.com"]
  C -->|"GET /r/sub/new · /search"| D["Listen: threads + comments"]
  D --> E["Analyse with Claude<br/>themes · sentiment · pain-points"]
  E --> F["Roadmap / product decisions"]
  C -->|"POST /api/comment · /api/submit"| G["Engage as codeAmani-Labs"]
  E -.->|"value-first reply"| G

Official Documentation

Resource URL
API reference https://www.reddit.com/dev/api
Data API Wiki (current rules, rate limits, access tiers) https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki
OAuth2 flow (legacy archive wiki) https://github.com/reddit-archive/reddit/wiki/OAuth2
API access rules / User-Agent (legacy archive wiki) https://github.com/reddit-archive/reddit/wiki/API
Register an app https://www.reddit.com/prefs/apps
snoowrap (Node wrapper — deprecated) https://github.com/not-an-aardvark/snoowrap
PRAW (Python wrapper) https://praw.readthedocs.io/

Heads-up on the archive wiki: reddit-archive/…/wiki/API still states the old 60 req/min figure. The current, authoritative rate limit is 100 QPM per OAuth client — always cross-check the Data API Wiki above for live rules.


1. Register an app

At https://www.reddit.com/prefs/apps (logged in as codeAmani-Labs), create an app. The type decides the OAuth flow:

App type Use it for Grant
script Server-side bot acting as one account (our brand account) password
web app Acting as other users via OAuth consent; long-lived bots authorization_code (+ refresh token)
installed app Public clients (mobile/SPA), no secret installed_client

You receive a client ID (under the app name) and a client secret. Store both server-side only.

# .env.local — never commit, never ship to the browser
REDDIT_CLIENT_ID=...
REDDIT_CLIENT_SECRET=...
REDDIT_USERNAME=codeAmani-Labs
REDDIT_PASSWORD=...                 # only for the script-app password grant
# Descriptive User-Agent is MANDATORY — generic ones get throttled/blocked.
REDDIT_USER_AGENT="web:com.codeamanilabs.listener:v1.0 (by /u/codeAmani-Labs)"

User-Agent format (from the API rules): <platform>:<app ID>:<version> (by /u/<username>). Reddit aggressively rate-limits or blocks default library agents (axios/x.y, python-requests). Always send a unique, descriptive UA.


2. Get an OAuth2 token

The token endpoint is POST https://www.reddit.com/api/v1/access_token, authenticated with HTTP Basic (client_id:client_secret). Tokens last ~1 hour — cache and refresh before expiry.

Read-only "listening" token (application-only)

Best for the listen/analyse half of the loop — no account actions, just reads.

// lib/reddit-auth.ts
const TOKEN_URL = "https://www.reddit.com/api/v1/access_token";

export async function getAppToken(): Promise<string> {
  const basic = Buffer.from(
    `${process.env.REDDIT_CLIENT_ID}:${process.env.REDDIT_CLIENT_SECRET}`
  ).toString("base64");

  const res = await fetch(TOKEN_URL, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
      "User-Agent": process.env.REDDIT_USER_AGENT!,
    },
    // Confidential clients (script / web app) use client_credentials.
    // Public "installed" apps use grant_type=https://oauth.reddit.com/grants/installed_client&device_id=...
    body: new URLSearchParams({ grant_type: "client_credentials" }),
  });

  if (!res.ok) throw new Error(`Reddit token failed: ${res.status}`);
  const { access_token } = (await res.json()) as { access_token: string };
  return access_token;
}

Posting token (script app, password grant)

Use this to act as codeAmani-Labs (comment / submit). The password grant only works for the developer account that owns a script-type app.

// Swap the body for the password grant; everything else is identical.
body: new URLSearchParams({
  grant_type: "password",
  username: process.env.REDDIT_USERNAME!,
  password: process.env.REDDIT_PASSWORD!,
}),

For a robust long-running bot prefer a web app + authorization_code flow with duration=permanent to obtain a refresh token, so you never store the account password. The password grant is the quickest path for a single brand account.


3. Authenticated requests → oauth.reddit.com

Once you hold a token, all API calls go to https://oauth.reddit.com (not www.reddit.com), with a bearer token and your UA. Modhashes are not needed under OAuth.

// lib/reddit.ts
const API = "https://oauth.reddit.com";

async function redditGet(path: string, token: string) {
  const res = await fetch(`${API}${path}`, {
    headers: {
      Authorization: `bearer ${token}`,
      "User-Agent": process.env.REDDIT_USER_AGENT!,
    },
  });
  // Respect the budget — see §5.
  logRateLimit(res.headers);
  if (!res.ok) throw new Error(`Reddit GET ${path} → ${res.status}`);
  return res.json();
}

Listen: newest posts in a subreddit

// GET /r/:subreddit/new  — limit ≤ 100, paginate with `after`
const data = await redditGet("/r/SaaS/new?limit=50", token);
const posts = data.data.children.map((c: any) => ({
  fullname: c.data.name,        // e.g. "t3_abc123" — the post's fullname
  title: c.data.title,
  body: c.data.selftext,
  url: `https://reddit.com${c.data.permalink}`,
  score: c.data.score,
  numComments: c.data.num_comments,
}));

Listen: search for threads we can answer

// Search ALL of Reddit
await redditGet(`/search?q=${encodeURIComponent("m-pesa integration nextjs")}&sort=new&limit=25`, token);

// Search WITHIN one subreddit (restrict_sr=true)
await redditGet(`/r/Kenya/search?q=mpesa+api&restrict_sr=true&sort=relevance&limit=25`, token);

// Find relevant communities to monitor
await redditGet(`/subreddits/search?q=saas&sort=relevance&limit=10`, token);

Engage: comment on a thread

thing_id is the fullname of the parent (t3_ = post, t1_ = comment). Requires the submit scope and a posting token.

async function redditPost(path: string, token: string, form: Record<string, string>) {
  const res = await fetch(`https://oauth.reddit.com${path}`, {
    method: "POST",
    headers: {
      Authorization: `bearer ${token}`,
      "Content-Type": "application/x-www-form-urlencoded",
      "User-Agent": process.env.REDDIT_USER_AGENT!,
    },
    body: new URLSearchParams(form),
  });
  if (!res.ok) throw new Error(`Reddit POST ${path} → ${res.status}`);
  return res.json();
}

// Reply to a post
await redditPost("/api/comment", token, {
  api_type: "json",
  thing_id: "t3_abc123",
  text: "Here's how we solved the STK Push idempotency problem…", // raw markdown
});

Engage: submit a self-post

// kind=self for a text post; kind=link with `url` for a link post.
await redditPost("/api/submit", token, {
  api_type: "json",
  sr: "kenya",
  kind: "self",
  title: "We open-sourced a Daraja M-Pesa helper for Next.js",
  text: "After shipping a few M-Pesa flows, here's what we learned…",
});

Pre-validate before submitting. GET /api/v1/{subreddit}/post_requirements returns mod rules (min/max title length, required flair, blacklisted words, allowed domains). Check it first to avoid auto-removals — and to respect the community.


4. snoowrap quickstart (Node)

A wrapper can hand-roll less auth + pagination. snoowrap handles tokens, the rate-limit budget, and listings.

⚠️ snoowrap is deprecated. The npm package (snoowrap@1.23.0, last real release ~2020) is now flagged "no longer supported" and its bundled types track Reddit's older OAuth response shapes — the compiler will confidently lie as the API drifts. For new codeAmani code prefer the plain-fetch path in §2–§3 (one bearer token, your own types); reach for snoowrap only for a quick throwaway script.

npm install snoowrap   # deprecated — see the warning above
import Snoowrap from "snoowrap";

const r = new Snoowrap({
  userAgent: process.env.REDDIT_USER_AGENT!,
  clientId: process.env.REDDIT_CLIENT_ID!,
  clientSecret: process.env.REDDIT_CLIENT_SECRET!,
  username: process.env.REDDIT_USERNAME!,
  password: process.env.REDDIT_PASSWORD!,
});

// Listen
const newPosts = await r.getSubreddit("SaaS").getNew({ limit: 50 });
const hits = await r.search({ query: "mpesa api", sort: "new", time: "week" });

// Engage
await r.getSubmission("abc123").reply("Genuinely useful answer here…");

snoowrap is unmaintained (see the warning above), so on Node the plain-fetch path ages better. For pure data-collection / analysis pipelines, PRAW is the actively-maintained Python option and pairs naturally with pandas/Claude for theme clustering. PRAW is now on the 8.x line (pip install praw → praw==8.0.3, requires Python 3.10+); the 8.0 major dropped Python 3.8/3.9, made most listing/submit arguments keyword-only, and merged submit_image/submit_video/submit_gallery into a single submit().


5. Rate limits & resilience

OAuth clients get 100 queries/minute (QPM) per OAuth client ID, averaged over a rolling 10-minute window (so short bursts are fine). Unauthenticated / non-OAuth traffic is capped at 10 QPM and is effectively blocked for anything real — always send OAuth. (The old 60/min number you'll still see on the archived wiki predates the 2023 API changes; ignore it.) Every response carries the live budget — read it and back off rather than hammering:

function logRateLimit(h: Headers) {
  const remaining = Number(h.get("x-ratelimit-remaining") ?? "100");
  const reset = Number(h.get("x-ratelimit-reset") ?? "0"); // seconds until window reset
  if (remaining < 5) {
    // Sleep until the window resets instead of risking a 429.
    console.warn(`Reddit budget low: ${remaining} left, reset in ${reset}s`);
  }
}
Header Meaning
X-Ratelimit-Used Requests used this window
X-Ratelimit-Remaining Requests left this window
X-Ratelimit-Reset Seconds until the window resets

On 429, honour X-Ratelimit-Reset (or Retry-After) and retry with backoff. Cache tokens (~1 h) and listing results — listening doesn't need to be real-time.

Access tiers & pricing (post-2023)

Since Reddit's 2023 API changes the Data API is tiered — the guide's default use (voice-of-customer research on one brand account) sits comfortably in the free tier, but know where the line is:

Tier Who Limit / cost
Free Personal projects, bots, mod tools, non-commercial/academic research Self-serve, ≤100 QPM per OAuth client, no commercial use
Commercial / enterprise Ad-supported apps, paywalled or monetized products, bulk data Approved contract required (manual review, ~weeks); Reddit's published enterprise rate is ~$0.24 per 1,000 API calls

codeAmani notes

Security

Culture & ToS — this is the part that matters most

AI routing (ties to the AI Routing Policy)

Market fit (US-first, Kenya per-project)

Official docs:

Remote Access Integration Guide

Technology: remote-access · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/remote-access

Insight:

Every tool here answers one question — how do I reach a machine that isn't in front of me — and the good answers all share a shape: identity over network position. SSH is the deep one: an Ed25519 keypair, a ~/.ssh/config block, and ProxyJump mean exactly one box on your estate has a public port, and -L/-R/-D carry any TCP stream through that single encrypted channel — so a private Postgres becomes localhost:5432 without widening one allowlist. Modern OpenSSH does more of the work for you than the folklore suggests: ssh-keygen has defaulted to Ed25519 since 9.5, DSA is gone as of 10.0, key agreement is post-quantum hybrid by default, and sshd ships its own brute-force penalty box. The one default that still bites: sshd accepts passwords until you turn them off.

██████╗ ███████╗███╗   ███╗ ██████╗ ████████╗███████╗     █████╗  ██████╗ ██████╗███████╗███████╗███████╗
██╔══██╗██╔════╝████╗ ████║██╔═══██╗╚══██╔══╝██╔════╝    ██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
██████╔╝█████╗  ██╔████╔██║██║   ██║   ██║   █████╗      ███████║██║     ██║     █████╗  ███████╗███████╗
██╔══██╗██╔══╝  ██║╚██╔╝██║██║   ██║   ██║   ██╔══╝      ██╔══██║██║     ██║     ██╔══╝  ╚════██║╚════██║
██║  ██║███████╗██║ ╚═╝ ██║╚██████╔╝   ██║   ███████╗    ██║  ██║╚██████╗╚██████╗███████╗███████║███████║
╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝ ╚═════╝    ╚═╝   ╚══════╝    ╚═╝  ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝

Remote Access Integration Guide

Focus: securely reaching a machine or service that isn't directly exposed — a box behind a firewall, a localhost dev server Daraja needs to call, a Raspberry Pi on the office LAN, an Ubuntu distro inside WSL. Deep on SSH (keys, ~/.ssh/config, bastions, agent, forwarding, hardening), then tunnels, mesh VPN, and remote dev. Grounded in the OpenSSH man pages, ngrok, Cloudflare and Tailscale docs; reviewed 2026-08-23 against OpenSSH 10.5p1.

Table of contents

  1. Overview & decision tree
  2. SSH keys
  3. The SSH agent (and why forwarding is dangerous)
  4. ~/.ssh/config — host blocks
  5. Bastions & ProxyJump
  6. Port forwarding: -L, -R, -D
  7. Hardening sshd
  8. SSH and WSL Ubuntu
  9. Public tunnels (ngrok, Cloudflare Tunnel)
  10. VPN & zero-trust (Tailscale, WireGuard, Access)
  11. RDP / VNC
  12. Remote development
  13. Security checklist
  14. Troubleshooting
  15. codeAmani notes

Official documentation

Source URL What it covers
OpenSSH ssh manual https://www.man7.org/linux/man-pages/man1/ssh.1.html -L/-R/-D forwarding, -J jump host, -A, -N, -W, escape sequences
OpenSSH ssh_config https://www.man7.org/linux/man-pages/man5/ssh_config.5.html ~/.ssh/config, ProxyJump, IdentityFile, ControlMaster
OpenSSH sshd_config https://www.man7.org/linux/man-pages/man5/sshd_config.5.html Server hardening, PerSourcePenalties, AllowUsers, Match
OpenSSH ssh-keygen https://www.man7.org/linux/man-pages/man1/ssh-keygen.1.html Key generation, fingerprints, certificates, -R
OpenSSH release notes https://www.openssh.org/releasenotes.html What changed per release — Ed25519 default, DSA removal, PQ key exchange
ngrok getting started https://ngrok.com/docs/getting-started/ ngrok http, authtoken, auto-HTTPS, inspector
ngrok free plan limits https://ngrok.com/docs/pricing-limits/free-plan-limits/ Free static dev domain, endpoint cap, browser interstitial
Cloudflare Tunnel https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/ cloudflared tunnel create/route/run, outbound-only model
Tailscale CLI https://tailscale.com/kb/1080/cli up/set/status/ssh/funnel, exit nodes
Tailscale SSH https://tailscale.com/kb/1193/tailscale-ssh tailscale set --ssh, SSH ACL grants, check mode
What is Tailscale https://tailscale.com/kb/1151/what-is-tailscale WireGuard mesh, zero-trust, MagicDNS, ACLs
WSL networking https://learn.microsoft.com/windows/wsl/networking NAT vs mirrored mode, netsh portproxy, Hyper-V firewall

1. Overview & decision tree

"Remote access" is one verb — reach a thing that's elsewhere — answered by a handful of tools that trade off predictably:

flowchart TD
    Q{"What do you need<br/>to reach?"}
    Q -->|"a shell on a routable box"| SSH["SSH + ~/.ssh/config<br/>ProxyJump through one bastion"]
    Q -->|"the internet must hit<br/>my localhost"| TUN["ngrok / cloudflared<br/>public HTTPS front door"]
    Q -->|"many services,<br/>whole team, no open ports"| MESH["Tailscale / WireGuard<br/>identity-gated mesh"]
    Q -->|"a graphical desktop"| GUI["RDP/VNC<br/>tunnelled, never raw"]
    SSH --> FWD["-L / -R / -D<br/>carry any TCP stream"]
    classDef accent fill:#0891B2,color:#fff,stroke:#22D3EE
    classDef muted fill:#1e293b,color:#e2e8f0,stroke:#334155
    class SSH,TUN,MESH accent
    class GUI,FWD muted

Security is the constant: keys not passwords, least privilege, MFA, and an audit trail.

What changed in OpenSSH recently

Folklore about SSH ages badly. The current baseline (OpenSSH 10.5p1, released 2026-08-11):

Since Change What it means for you
8.8 ssh-rsa (RSA/SHA-1 signatures) disabled by default Ancient servers may reject your RSA key — regenerate as Ed25519 rather than re-enabling SHA-1
9.5 ssh-keygen generates Ed25519 by default ssh-keygen with no flags is already the right answer; -t ed25519 is documentation, not necessity
9.8 PerSourcePenalties — sshd's built-in penalty box The server already throttles brute-forcers before you install fail2ban
9.9 → 10.0 Hybrid post-quantum key agreement mlkem768x25519-sha256 is the default Harvest-now-decrypt-later is covered on both ends running ≥10.0; no config needed
10.0 DSA removed entirely ssh-dss keys are dead. If a device still needs DSA, it needs replacing, not a config exception

2. SSH keys

Generate

The private key never leaves your machine; only the .pub half is ever copied anywhere.

# Ed25519 — small, fast, and the ssh-keygen default since OpenSSH 9.5.
# -C is a free-text comment (shows up in authorized_keys — make it identify the key).
# -f names the file so you can keep per-purpose keys instead of one id_ed25519 for everything.
ssh-keygen -t ed25519 -C "barnabas@codeamani-laptop" -f ~/.ssh/id_ed25519

# Same, with a passphrase-hardened private key: -a sets KDF rounds (higher = slower to
# brute-force if the file is stolen). 100 is a common, comfortable value.
ssh-keygen -t ed25519 -a 100 -C "barnabas@codeamani-laptop" -f ~/.ssh/id_ed25519

# Only if a legacy appliance genuinely can't do Ed25519:
ssh-keygen -t rsa -b 4096 -C "legacy-appliance-only"

Always set a passphrase. An unprotected private key is a bearer token sitting in a file — anyone who copies it is you. The passphrase is what makes a stolen laptop a nuisance instead of a breach, and the agent (§3) means you type it once per boot.

Use one key per device, not one key per human. Losing a laptop should mean deleting one line from authorized_keys, not rotating every server you own.

Install the public half

# Easiest — needs an existing way in (password auth, or another key already installed)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com

# Manual equivalent when ssh-copy-id isn't available (permissions matter — see below)
cat ~/.ssh/id_ed25519.pub | ssh user@server.example.com \
  'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

# Verify before you disable password auth — keep the working session open!
ssh -i ~/.ssh/id_ed25519 user@server.example.com

Permissions are enforced, not advisory. ~/.ssh must be 700, private keys 600, and authorized_keys 600. If the mode is looser, ssh refuses the key with UNPROTECTED PRIVATE KEY FILE and sshd silently ignores authorized_keys. This is the #1 cause of "my key just doesn't work" — and the #1 reason not to keep keys on a Windows mount inside WSL (§8).

Host key verification (the direction people forget)

Keys prove you to the server. The host key proves the server to you — it's what stops an on-path attacker from impersonating your bastion.

# First connect is trust-on-first-use: you're shown a fingerprint and asked to accept.
# Compare it out-of-band against the server's own fingerprint before typing yes:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub    # run on the server

# Pre-seed known_hosts for CI / scripted access instead of disabling checking
ssh-keyscan -t ed25519 server.example.com >> ~/.ssh/known_hosts

# Host key changed (rebuild, re-image, new VM at the same address)? Remove the stale entry:
ssh-keygen -R server.example.com

StrictHostKeyChecking accept-new auto-accepts a first fingerprint but still refuses a changed one — a reasonable middle ground for ephemeral infra. Never StrictHostKeyChecking no: that accepts changed keys too, which is precisely the attack it exists to catch.


3. The SSH agent (and why forwarding is dangerous)

The agent holds your decrypted private key in memory so you type the passphrase once instead of on every connection.

eval "$(ssh-agent -s)"          # start one (most desktops already run it)
ssh-add ~/.ssh/id_ed25519       # unlock the key into the agent
ssh-add -l                      # list loaded keys
ssh-add -t 8h ~/.ssh/id_ed25519 # auto-expire after 8 hours
ssh-add -c ~/.ssh/id_ed25519    # require confirmation on EVERY use of this key
ssh-add -D                      # drop all keys (do this when you step away)

Set AddKeysToAgent yes in ~/.ssh/config and the key loads on first use automatically. On macOS add UseKeychain yes to persist the passphrase in the Keychain. On Windows, the agent is a service: Start-Service ssh-agent; Set-Service ssh-agent -StartupType Automatic.

Agent forwarding: what it actually risks

ForwardAgent yes / ssh -A exposes your local agent's socket on the remote host so you can hop onward using your local keys. The ssh(1) man page is blunt about the consequence:

Users with the ability to bypass file permissions on the remote host (for the agent's Unix-domain socket) can access the local agent through the forwarded connection. An attacker cannot obtain key material from the agent, however they can perform operations on the keys that enable them to authenticate using the identities loaded into the agent.

Read that carefully: root on the hop cannot steal your key, but for as long as your session is open they can sign with it — i.e. log in as you to every machine that key opens. On a shared or compromised bastion that's a full lateral-movement primitive.

Use ProxyJump instead (§5). It solves the same problem — reach host B via host A — without ever exposing your agent to A, because the SSH session is encrypted end-to-end from your laptop to B and A only relays bytes. Default ForwardAgent no globally, and if some workflow genuinely needs forwarding, scope it to a single trusted host and pair it with ssh-add -c so each use requires an explicit confirmation:

Host *
    ForwardAgent no          # default deny

Host trusted-build-box
    ForwardAgent yes         # opt in for exactly one host, deliberately

4. ~/.ssh/config — host blocks

A config block turns a long command into ssh prod, and it's where bastion routing, identity selection and keepalives live. Every tool that shells out to ssh — git, rsync, scp, VS Code Remote-SSH, Ansible — inherits it for free.

# ~/.ssh/config   (chmod 600)

# ── The bastion: the ONLY box with a public SSH port ──────────────────
Host bastion
    HostName bastion.example.com
    User jumpuser
    Port 22
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes            # offer ONLY this key (see gotcha below)
    ForwardAgent no

# ── A private box reachable only through the bastion ──────────────────
Host app-internal
    HostName 10.0.1.50            # private IP, no public exposure
    User appuser
    ProxyJump bastion             # SSH hops through bastion automatically
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

# ── Pattern matching: one block for a whole fleet ─────────────────────
Host db-* cache-*
    User ops
    ProxyJump bastion
    IdentityFile ~/.ssh/id_ed25519

# ── A distinct key for GitHub (keeps work/personal identities apart) ──
Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_github
    IdentitiesOnly yes

# ── Shared defaults. MUST BE LAST — see first-match-wins below. ────────
Host *
    AddKeysToAgent yes
    ForwardAgent no
    ServerAliveInterval 60        # ping every 60s...
    ServerAliveCountMax 3         # ...give up after 3 misses (~3 min dead link)
    ControlMaster auto            # reuse one TCP+auth session for repeat connects
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

Three things worth internalising:

ssh -G app-internal prints the fully-resolved config for a host — the fastest way to find out which block actually won.


5. Bastions & ProxyJump

The bastion (jump host) pattern: exactly one hardened box has a public SSH port; everything else lives on private IPs and is reached through it. One place to audit, one place to patch, one place to revoke.

Host app-internal
    HostName 10.0.1.50
    ProxyJump bastion
ssh app-internal                                        # config-driven
ssh -J jumpuser@bastion.example.com appuser@10.0.1.50   # one-shot equivalent
ssh -J hop1,hop2 user@final-host                        # chain multiple hops

ProxyJump is not agent forwarding. Under the hood it runs a nested ssh -W host:port on the jump host, which merely relays TCP. Your session to the final host is encrypted end-to-end from your laptop — the bastion sees ciphertext, never your keystrokes and never your agent. That property is the whole reason to prefer it.

flowchart LR
    Dev["Your laptop"] -->|"SSH session, encrypted end-to-end"| App["app-internal 10.0.1.50"]
    Dev -.->|"outer SSH: relay only"| B["bastion :22<br/>sees ciphertext"]
    B -.->|"forwards TCP"| App
    classDef pub fill:#0891B2,color:#fff,stroke:#22D3EE
    classDef priv fill:#1e293b,color:#e2e8f0,stroke:#334155
    class B pub
    class App priv

ProxyCommand is the older, more general escape hatch (ProxyCommand ssh -W %h:%p bastion, or an AWS SSM / Cloudflare cloudflared access ssh invocation). Reach for ProxyJump unless you need something ProxyJump can't express.

On the bastion itself, keep the blast radius small: no application code, no secrets, AllowTcpForwarding yes (it needs it) but AllowAgentForwarding no, per-user accounts, and full session logging.


6. Port forwarding: -L, -R, -D

SSH's most useful and least-known feature: carry an arbitrary TCP stream through the encrypted connection. -N means "no shell, just hold the tunnel open"; add -f to background it.

# LOCAL forward (-L): pull a remote/private service onto YOUR localhost.
# Reach a Postgres on a private subnet as if it were local:5432.
ssh -N -L 5432:db.internal:5432 user@bastion.example.com
#        └local┘ └──remote target──┘
# → psql -h localhost -p 5432   now hits db.internal through the tunnel.
# The "db.internal:5432" part is resolved BY THE BASTION, not by you — which is
# why this works for hostnames that don't resolve on your machine at all.

# REMOTE forward (-R): push YOUR localhost out to a port on the remote box.
ssh -N -R 8080:localhost:3000 user@server.example.com
# → server.example.com:8080 reaches your laptop's :3000.
# By default this binds the remote's LOOPBACK only. To expose it on the remote's
# public interface the SERVER must set `GatewayPorts yes` (or `clientspecified`),
# then: ssh -N -R 0.0.0.0:8080:localhost:3000 user@server.example.com

# DYNAMIC forward (-D): a local SOCKS5 proxy that egresses from the remote host.
ssh -N -D 1080 user@bastion.example.com
# → curl --socks5-hostname localhost:1080 https://internal-dashboard.example.com
# Point a browser's SOCKS settings at localhost:1080 and every request exits
# from the bastion — the cleanest way to browse an internal admin panel without
# forwarding each service individually.
flowchart LR
    Dev["Your laptop<br/>localhost:5432"] -->|"encrypted SSH"| B["Bastion<br/>public :22"]
    B -->|"private LAN"| DB[("db.internal:5432<br/>no public port")]
    classDef pub fill:#0891B2,color:#fff,stroke:#22D3EE
    classDef priv fill:#1e293b,color:#e2e8f0,stroke:#334155
    class B pub
    class DB priv

The mnemonic: -L brings something to you (Local); -R sends something out to the Remote; -D makes everything Dynamic through a SOCKS proxy.

Escape sequences (typed at the start of a line in an interactive session) save you when a forward is missing or a session hangs:

Sequence Effect
~? List all escape sequences
~C Open a command line — add a forward mid-session: -L 8080:localhost:80
~. Kill a hung session (when Ctrl-C won't work)
~& Background the session

On the server side, AllowTcpForwarding no in sshd_config disables all of this — set it on any host that has no business relaying traffic, and leave it on only for bastions.


7. Hardening sshd

The defaults in sshd_config are permissive on purpose — OpenSSH ships something that works everywhere, and expects you to lock it down. These are the directives that matter, with their documented defaults:

Directive Default Set to Why
PasswordAuthentication yes no Passwords are brute-forced continuously; keys are not
KbdInteractiveAuthentication yes no The forgotten back door — PAM keyboard-interactive still accepts passwords even after you disable PasswordAuthentication
PermitRootLogin prohibit-password no Per-user accounts + sudo give you an audit trail
PubkeyAuthentication yes yes Keep explicit so a future edit can't quietly flip it
MaxAuthTries 6 3 Fewer guesses per connection
AllowUsers / AllowGroups all users explicit list Allowlist beats denylist
PermitEmptyPasswords no no Assert it
X11Forwarding no (often yes on distros) no Unused attack surface on a server
AllowTcpForwarding yes no (except bastions) Stops a compromised account pivoting through the box
AllowAgentForwarding yes no Don't let a hop harvest visiting agents
PerSourcePenalties enabled (9.8+) leave on Built-in penalty box for crashes, auth failures, invalid users
# Edit, then ALWAYS validate before restarting
sudo nano /etc/ssh/sshd_config
sudo sshd -t                    # syntax check — silence means OK
sudo sshd -T | grep -Ei 'passwordauth|permitrootlogin|kbdinteractive'   # effective config
sudo systemctl restart ssh

# ⚠ Keep your CURRENT session open and verify a NEW one connects before closing it.
# A typo in sshd_config plus a closed last session is a locked-out server.

Two distro gotchas that waste afternoons:

PerSourcePenalties vs fail2ban

Since OpenSSH 9.8, sshd penalises misbehaving source addresses itself — enabled by default, with per-event penalties (auth failure 5s, invalid user 5s, crash 90s, …) accumulating up to a 10-minute cap. For a single host with keys-only auth, that plus PasswordAuthentication no already removes essentially all brute-force value.

fail2ban still earns its place when you want firewall-level bans (dropping packets rather than answering them), longer ban windows, bans shared across services (SSH + nginx + postfix), or a ban list you can inspect and report on.

# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 3

[sshd]
enabled = true
# Ubuntu 24.04 / Debian 12 log SSH to journald and may have NO /var/log/auth.log.
# With the default `backend = auto`, fail2ban then reads nothing and silently
# never bans anyone. Point it at the journal explicitly:
backend = systemd
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd     # verify it is actually seeing failures

Beyond authorized_keys: SSH certificates

authorized_keys sprawl is the real operational problem at fleet scale — one departing engineer means editing every server. SSH certificates invert it: a CA signs short-lived user certificates, servers trust the CA, and expiry does the revocation for you.

# On the CA host (protect this key like a root credential)
ssh-keygen -t ed25519 -f ~/ca_user_key -C "codeamani-user-ca"

# Sign a user's public key for 8 hours, valid as principal "appuser"
ssh-keygen -s ~/ca_user_key -I "barnabas@codeamani" -n appuser -V +8h \
  ~/.ssh/id_ed25519.pub          # → produces id_ed25519-cert.pub

# On every server: trust the CA instead of listing individual keys
#   /etc/ssh/sshd_config
#   TrustedUserCAKeys /etc/ssh/ca_user_key.pub

If you'd rather not run a CA, Tailscale SSH (§10) and Cloudflare Access give you the same "identity, not a file on a laptop" property as a managed service.


8. SSH and WSL Ubuntu

WSL trips people up because there are two operating systems with two separate SSH worlds on one machine. See wsl/CLAUDE_CODE_INTEGRATION.md (§7 configuration, §10 networking) for the WSL fundamentals; this section covers only the SSH-shaped parts.

SSH out of WSL (the common case)

The distro's ~/.ssh is at /home/you/.ssh inside the Linux filesystem, entirely separate from Windows' C:\Users\you\.ssh. Simplest correct answer: generate a distro-local key and treat the distro as its own device.

# Inside Ubuntu on WSL
ssh-keygen -t ed25519 -a 100 -C "barnabas@wsl-ubuntu"
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com

Do not symlink ~/.ssh to /mnt/c/Users/you/.ssh. Without [automount] options = "metadata" in /etc/wsl.conf, everything under /mnt/c reports mode 0777, and ssh refuses the key outright with UNPROTECTED PRIVATE KEY FILE. Enabling metadata (then wsl --shutdown, then chmod 600) does make it work, but you've now got one key whose permissions depend on a mount option — a fragile setup. Copying the key in and chmod 600-ing it, or issuing a separate key, is the durable choice.

If you want a single key custody point across both OSes, bridge the Windows agent into WSL with npiperelay + socat (or a wrapper like wsl2-ssh-agent) and set SSH_AUTH_SOCK in your shell rc. That keeps the private key in the Windows agent — or in 1Password/ssh-agent.exe — with WSL holding no key material at all. Worth the setup cost only if you're already curating Windows-side keys.

SSH into WSL

Two distinct problems: getting sshd running inside the distro, and making the distro reachable at all.

# 1. Install and start the server inside Ubuntu
sudo apt update && sudo apt install -y openssh-server
sudo ssh-keygen -A                      # generate host keys if the install didn't

# Avoid colliding with the Windows host's own OpenSSH server on :22
sudo sed -i 's/^#\?Port .*/Port 2222/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sshd -t

# 2. Start it. With systemd enabled ([boot] systemd=true in /etc/wsl.conf):
sudo systemctl enable --now ssh
# Without systemd, WSL has no init — start it per session (or from ~/.bashrc):
sudo service ssh start

Reachability depends on the networking mode:

Mode Reaching WSL's sshd Notes
NAT (default) From Windows itself, localhost:2222 generally works via localhost forwarding. From another machine on the LAN it does not — the distro is a VM behind NAT Bridge it with netsh interface portproxy add v4tov4 listenport=2222 listenaddress=0.0.0.0 connectport=2222 connectaddress=$(wsl hostname -I) — but the WSL IP changes on restart, so this needs re-running
Mirrored (networkingMode=mirrored in %UserProfile%\.wslconfig; Win 11 22H2 + WSL 2.0.9+) WSL mirrors the host's interfaces, so a LAN peer can reach the distro directly Requires opening the Hyper-V firewall for inbound: Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow, or a targeted New-NetFirewallHyperVRule per port

Remember wsl --shutdown after editing .wslconfig — the setting only applies to a fresh VM.

The codeAmani answer is usually neither. Run Tailscale inside the distro (or on the Windows host with mirrored networking) and reach it by MagicDNS name over the tailnet — no port forwarding, no IP that changes on reboot, no router config, and identity-gated access. Reserve the portproxy dance for one-off LAN testing.


9. Public tunnels (ngrok, Cloudflare Tunnel)

Daraja, Stripe, Clerk and every other webhook provider POST to a public HTTPS URL. Your dev server on http://localhost:3000 is invisible to them. A tunnel gives localhost a public HTTPS front door.

ngrok — fastest path

# One-time: register your account's authtoken
ngrok config add-authtoken <YOUR_TOKEN>

# Ephemeral URL — fine for a five-minute test
ngrok http 3000

# Better: bind your account's free STATIC dev domain so the URL survives restarts
ngrok http 3000 --url https://<YOUR-DEV-DOMAIN>.ngrok-free.app

Every ngrok account — free tier included — now gets one automatically assigned static dev domain, so the old ritual of re-registering a fresh random URL in the Daraja portal after every restart is no longer necessary. Bind it with --url and the callback URL you configured stays valid. (--subdomain and --hostname are deprecated in favour of --domain/--url.)

Free plan caveats worth knowing before you debug something that isn't broken:

Cloudflare Tunnel (cloudflared) — durable, no open ports

cloudflared makes an outbound-only connection to Cloudflare's edge; your firewall stays fully closed to inbound traffic, and you get a stable hostname on your own domain (which matters when a provider whitelists callback domains).

cloudflared tunnel login                                   # browser auth, picks a zone
cloudflared tunnel create daraja-dev                       # named tunnel + UUID
cloudflared tunnel route dns daraja-dev cb.codeamani.com   # map a hostname

# ~/.cloudflared/config.yml
#   tunnel: <UUID>
#   credentials-file: /home/you/.cloudflared/<UUID>.json
#   ingress:
#     - hostname: cb.codeamani.com
#       service: http://localhost:3000
#     - service: http_status:404

cloudflared tunnel run daraja-dev                          # bring it up

# Throwaway alternative — no account, no config, random *.trycloudflare.com URL:
cloudflared tunnel --url http://localhost:3000
flowchart LR
    Daraja["Safaricom Daraja"] -->|"HTTPS POST callback"| CF["Cloudflare edge"]
    CF -.->|"outbound-only tunnel<br/>firewall stays shut"| CFD["cloudflared on laptop"]
    CFD --> App["Next.js<br/>localhost:3000<br/>/api/mpesa/callback"]
    classDef accent fill:#0891B2,color:#fff,stroke:#22D3EE
    class CF,CFD accent
ngrok Cloudflare Tunnel Tailscale Funnel
Setup seconds a few minutes seconds (if already on the tailnet)
URL free static dev domain; custom on paid your own domain hostname <device>.<tailnet>.ts.net
Needs a domain you own no yes (a Cloudflare zone) no
Inbound ports none none (outbound-only) none
Best for throwaway webhook testing, request inspection longer-lived previews, IP/domain-restricted providers sharing a dev service you're already meshing

10. VPN & zero-trust (Tailscale, WireGuard, Access)

Tunnels and bastions expose one path at a time. A zero-trust mesh flips the model: machines join a private encrypted network and access is granted by identity + policy, not by where a packet originates.

Tailscale (managed WireGuard mesh)

Tailscale builds an encrypted peer-to-peer WireGuard mesh (a "tailnet"). No central gateway to bottleneck, no inbound ports, and devices authenticate against your existing IdP.

tailscale up                       # auth via browser/IdP, join the tailnet
tailscale status                   # peers + their 100.x.y.z addresses
tailscale ip -4                    # this device's tailnet IPv4

tailscale set --ssh                # enable identity-gated Tailscale SSH on THIS device
ssh pi@raspberry-pi                # connect by MagicDNS name — no authorized_keys at all
tailscale ssh pi@raspberry-pi      # equivalent via the Tailscale CLI

tailscale funnel 3000              # expose a local port publicly at https://<device>.<tailnet>.ts.net

tailscale set --ssh is the current documented way to turn the SSH server on (tailscale up --ssh still works and is what older docs show). Access needs both a normal ACL permitting the connection and an SSH rule in the policy file:

{
  "action": "accept",
  "src":    ["autogroup:member"],
  "dst":    ["autogroup:self"],
  "users":  ["autogroup:nonroot"]
}

"action": "check" instead of "accept" forces periodic re-authentication — sessions reset after 12 hours by default (checkPeriod). Limits to plan around: the SSH server runs on Linux and macOS open-source builds only, port 22 is assumed and not configurable, restarting tailscaled drops live sessions, and "checkPeriod": "always" will break automation like Ansible.

The free Personal plan currently covers up to 6 users with unlimited user devices, 3 ACL groups, 50 tagged resources, and Tailscale SSH on up to 5 hosts — comfortably enough for a dev fleet and a handful of Raspberry Pis. MagicDNS gives you stable names (raspberry-pi) instead of 100.x.y.z addresses; use names in scripts.

WireGuard (the raw protocol)

Tailscale is WireGuard with identity, key distribution and NAT traversal bolted on. Plain WireGuard (wg, wg-quick up wg0) is the DIY option — you manage keys and peer config yourself. Reach for it when you want a single self-hosted VPN concentrator and no third party in the path, and accept that key rotation and device revocation become your job.

Cloudflare Access (zero-trust for HTTP)

Pairs with Cloudflare Tunnel: put an internal app behind a Tunnel, then enforce an identity policy (email domain, IdP group, MFA) at Cloudflare's edge before any request reaches your origin. No VPN client, no open port — the app is private but reachable by exactly the right people. cloudflared access ssh extends the same policy layer to SSH via a ProxyCommand.


11. RDP / VNC

When you need a screen, not a shell:


12. Remote development

Run the editor locally, execute remotely — so code lives next to the data, GPU, or private network it needs.


13. Security checklist

Remote access is the front door to your infrastructure — treat every item as mandatory, not optional.


14. Troubleshooting

Symptom Likely cause / fix
Permission denied (publickey) Public key not in the server's authorized_keys, or wrong user. Diagnose with ssh -vvv user@host and read which keys were offered
UNPROTECTED PRIVATE KEY FILE chmod 600 ~/.ssh/id_ed25519, chmod 700 ~/.ssh. On WSL, the key is probably on /mnt/c — move it into the Linux filesystem (§8)
Key ignored server-side, no error ~/.ssh or authorized_keys too permissive on the server; sshd silently skips them. Check sudo journalctl -u ssh
Too many authentication failures The agent is offering every key before the right one. Add IdentitiesOnly yes + an explicit IdentityFile
REMOTE HOST IDENTIFICATION HAS CHANGED Server rebuilt/re-imaged (or an on-path attack). Confirm the cause, then ssh-keygen -R host
Config edits have no effect First-match-wins: an earlier block (often Host * at the top) already set the keyword. Check with ssh -G host
sshd still accepts passwords after disabling Either KbdInteractiveAuthentication yes is still on, or a drop-in in /etc/ssh/sshd_config.d/ overrides you. Verify with sudo sshd -T | grep -i auth
Port change in sshd_config ignored (Ubuntu 22.10+) ssh.socket owns the port — systemctl edit ssh.socket and set ListenStream=
-R forward not reachable from outside the remote Remote binds loopback by default; needs GatewayPorts yes server-side and -R 0.0.0.0:PORT:...
Connection drops after idle ServerAliveInterval 60 + ServerAliveCountMax 3 in ~/.ssh/config
fail2ban never bans anything (Ubuntu 24.04) No /var/log/auth.log; set backend = systemd in the [sshd] jail
Webhook provider gets a 404 through the tunnel Tunnel points at the wrong port, or cloudflared ingress falls through to http_status:404 — hostname must match exactly
ngrok shows a warning page instead of the app Free-plan browser interstitial. Harmless for webhooks; send ngrok-skip-browser-warning to bypass
Can't reach WSL's sshd from another machine Default NAT mode. Use networkingMode=mirrored + a Hyper-V firewall rule, or netsh interface portproxy (§8)
Tailscale SSH refuses the connection Missing the SSH rule in the policy file — a normal ACL grant alone isn't enough

15. codeAmani notes

Official docs:

Render Integration Guide

Technology: render · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/render

Insight:

Render covers what serverless can't — long-running services, background workers, cron jobs, and managed Postgres/Redis with persistent connections. Reach for it when you need an always-on server process rather than the Vercel/Netlify function model.

██████╗ ███████╗███╗   ██╗██████╗ ███████╗██████╗
██╔══██╗██╔════╝████╗  ██║██╔══██╗██╔════╝██╔══██╗
██████╔╝█████╗  ██╔██╗ ██║██║  ██║█████╗  ██████╔╝
██╔══██╗██╔══╝  ██║╚██╗██║██║  ██║██╔══╝  ██╔══██╗
██║  ██║███████╗██║ ╚████║██████╔╝███████╗██║  ██║
╚═╝  ╚═╝╚══════╝╚═╝  ╚═══╝╚═════╝ ╚══════╝╚═╝  ╚═╝

Render Integration Guide

Focus: Managing Render cloud infrastructure from Claude Code using the official Render MCP server and REST API automation.

Overview

Render is a unified cloud platform for deploying web services, private services, static sites, background workers, cron jobs, and managed Postgres / Key Value (Redis-compatible) datastores. The official Render MCP server (GA on 2025-08-21) lets Claude Code inspect services, query databases, fetch logs, analyze metrics, trigger deploys, and create new resources — all through natural language without leaving your session.

Here is the big picture — a single git push fans out into all of Render's service types, so you can reason about the whole platform at a glance:

flowchart LR
  A["git push to main"] --> B["Render auto-build"]
  B --> C["Web service"]
  B --> D["Background worker"]
  B --> E["Cron job"]
  B --> F["Static site"]
  C --> G["Managed Postgres / Redis"]
  D --> G
  E --> G

Official Documentation

Resource URL
Render Docs https://render.com/docs
Render MCP Server https://render.com/docs/mcp-server
MCP server source https://github.com/render-oss/render-mcp-server
AI/LLM Support https://render.com/docs/llm-support
Render REST API https://api-docs.render.com
Blueprint (render.yaml) spec https://render.com/docs/blueprint-spec
Render Dashboard https://dashboard.render.com

MCP Server Setup

Render hosts an official MCP server at https://mcp.render.com/mcp. Authenticate with OAuth (recommended, browser sign-in) or with a Render API key (for non-interactive environments). The server is open source at github.com/render-oss/render-mcp-server; prefer the hosted endpoint over running it locally so it stays current as new tools ship.

You are about to give Claude Code a direct line into your infrastructure — here is how a natural-language request flows through the MCP server to your live services:

sequenceDiagram
  participant U as "You"
  participant C as "Claude Code"
  participant M as "Render MCP"
  participant R as "Render services"
  U->>C: "Why is my API down?"
  C->>M: list_services
  M->>R: query status
  R-->>M: status results
  M->>R: get_logs + get_metrics
  R-->>M: logs and metrics
  M-->>C: diagnostic data
  C-->>U: summary and fix
# OAuth (recommended) — registers the server, then you authorize in the browser
claude mcp add --transport http --client-id claude render https://mcp.render.com/mcp
# then run /mcp inside Claude Code → select "render" → Authenticate

# API key (non-interactive) — Bearer token instead of OAuth
claude mcp add --transport http render \
  https://mcp.render.com/mcp \
  --header "Authorization: Bearer ${RENDER_API_KEY}"

.mcp.json Configuration (API-key auth)

{
  "mcpServers": {
    "render": {
      "type": "http",
      "url": "https://mcp.render.com/mcp",
      "headers": {
        "Authorization": "Bearer ${RENDER_API_KEY}"
      }
    }
  }
}

Get your API key from: https://dashboard.render.com/u/settings → API Keys. After connecting, set the active workspace once per session — prompt "Set my Render workspace to <name>"; every tool call is scoped to that workspace.

Available MCP Tools (by resource)

Resource Actions
Workspaces list workspaces · set current workspace · get current workspace details
Services create (web service · static site · cron job · Postgres · Key Value) · list · get details · update all env vars
Deploys trigger a deploy (optionally clearing build cache) · list deploy history · get a deploy
Logs list logs by filter · list values for a log label
Metrics CPU / memory · instance count · datastore connection counts · response counts by status code · response times (Pro workspace+) · outbound bandwidth
Render Postgres create · list · get · run a read-only SQL query
Render Key Value create · list · get

What the MCP server can't do. It creates only web services, static sites, cron jobs, Postgres, and Key Value — not background workers, private services, or image-backed services, and it can't set IP allowlists. For existing services it only triggers deploys and updates env vars; it does not modify scaling settings or other operational controls. Use render.yaml or the REST API for those.


Render CLI

Render ships an official CLI (render, GA — v2.24.0 at review time; source at github.com/render-oss/cli). It complements the MCP server for terminal and CI/CD work — triggering deploys, tailing logs, opening a psql or SSH session, and validating Blueprints.

# Install (macOS/Linux)
brew install render        # or: curl -fsSL https://raw.githubusercontent.com/render-oss/cli/refs/heads/main/bin/install.sh | sh

render login               # browser CLI-token auth; then pick an active workspace
render workspace set       # switch the active workspace at any time
Command Does
render services List services/datastores in the active workspace (interactive menu)
render deploys create [SERVICE_ID] Trigger a deploy — --wait, --commit <sha>, --image <tag>
render deploys list [SERVICE_ID] Deploy history for a service
render psql [DATABASE_ID] Open psql; -c "SQL" runs one query and exits
render ssh [SERVICE_ID] SSH into a running instance; --ephemeral for an isolated shell
render blueprints validate [FILE] Validate a render.yaml (defaults to ./render.yaml)
render skills [install|list] Install Render agent skills for Claude Code / Codex / Cursor

For CI/CD, authenticate non-interactively with RENDER_API_KEY (takes precedence over CLI tokens) and pass -o json + --confirm:

export RENDER_API_KEY=rnd_...
render deploys create "$RENDER_SERVICE_ID" --output json --confirm --wait

REST API Integration

The REST API is the lowest-level programmatic interface, underneath both the CLI and the MCP server — reach for it when you need a field the CLI/MCP don't expose (e.g. creating background workers or private services).

Get your services

curl https://api.render.com/v1/services \
  -H "Authorization: Bearer $RENDER_API_KEY" \
  -H "Content-Type: application/json" | jq '.[] | {id, name, status}'

Trigger a manual deploy

SERVICE_ID="srv-..."
curl -X POST "https://api.render.com/v1/services/$SERVICE_ID/deploys" \
  -H "Authorization: Bearer $RENDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"clearCache": "do_not_clear"}'

Create a web service (TypeScript)

const response = await fetch("https://api.render.com/v1/services", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RENDER_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    // type enum: web_service | static_site | private_service | background_worker | cron_job
    type: "web_service",
    name: "my-api",
    ownerId: "usr-...",           // workspace/owner ID (usr-… or tea-…)
    repo: "https://github.com/my-org/my-repo", // repo is a plain URL string
    branch: "main",              // top-level; defaults to the repo's default branch
    autoDeploy: "yes",           // "yes" | "no"
    serviceDetails: {
      runtime: "node",           // field is "runtime" (docker|elixir|go|node|python|ruby|rust|image)
      plan: "starter",           // starter|standard|pro|pro_plus|pro_max|pro_ultra|free
      region: "oregon",
      // build/start commands live under envSpecificDetails for native runtimes
      envSpecificDetails: {
        buildCommand: "npm ci && npm run build",
        startCommand: "node dist/index.js",
      },
    },
  }),
});

const service = await response.json();
console.log("Created:", service.service.serviceDetails.url);

Python helper for Render API

import os, requests

RENDER_API_KEY = os.environ["RENDER_API_KEY"]
BASE = "https://api.render.com/v1"
HEADERS = {"Authorization": f"Bearer {RENDER_API_KEY}", "Content-Type": "application/json"}

def get_services():
    return requests.get(f"{BASE}/services", headers=HEADERS).json()

def get_logs(service_id: str, limit: int = 100):
    return requests.get(
        f"{BASE}/services/{service_id}/logs",
        headers=HEADERS,
        params={"limit": limit},
    ).json()

def trigger_deploy(service_id: str):
    return requests.post(
        f"{BASE}/services/{service_id}/deploys",
        headers=HEADERS,
        json={"clearCache": "do_not_clear"},
    ).json()

render.yaml Blueprint (Infrastructure as Code)

A Blueprint (render.yaml at your repo root) is the single source of truth for an interconnected set of services, databases, and environment groups. Commit it to Git, connect the repo in the Dashboard, and Render provisions everything in one pass. By default Render re-syncs affected resources on every push to the linked branch, so you manage infra the same way you manage code — via PRs and git push.

flowchart LR
  A["Edit render.yaml"] --> B["git push to linked branch"]
  B --> C["Render reads Blueprint"]
  C --> D["Sync web service<br/>build · start · scaling"]
  C --> E["Sync managed Postgres"]
  C --> F["Sync env var group"]
  D --> G["Live infrastructure"]
  E --> G
  F --> G

A web service + managed Postgres + a shared env group, fully wired:

# render.yaml
services:
  - type: web
    name: amani-api
    runtime: node
    plan: starter
    region: oregon
    buildCommand: npm ci && npm run build
    startCommand: node dist/index.js
    autoDeployTrigger: commit
    envVarGroups:
      - amani-shared
    envVars:
      # Wire the DATABASE_URL straight from the managed database below
      - key: DATABASE_URL
        fromDatabase:
          name: amani-db
          property: connectionString
      # Prompt for this secret once during Blueprint creation (never stored in Git)
      - key: RENDER_API_KEY
        sync: false
      # Let Render generate a strong random secret
      - key: SESSION_SECRET
        generateValue: true

databases:
  - name: amani-db
    plan: basic-256mb
    databaseName: amani
    user: amani
    region: oregon
    postgresMajorVersion: "17"

envVarGroups:
  - name: amani-shared
    envVars:
      - key: NODE_ENV
        value: production
      - key: TZ
        value: Africa/Nairobi

Gotcha — sync overwrites, but never deletes. Dashboard edits to a Blueprint-managed resource are overwritten on the next sync if they conflict with the YAML, so make changes in render.yaml, not the UI. Conversely, removing a resource from the file does not delete it — syncing never deletes existing resources, so you must delete them manually in the Dashboard. Also never manage one resource from two Blueprints, and list all fields when importing an existing resource (omitted fields fall back to defaults that likely differ from your current setup).


Environment Variables

# Required
RENDER_API_KEY=rnd_...          # From dashboard.render.com → Settings → API Keys

# Your service variables (set via dashboard or API)
DATABASE_URL=postgresql://...
PORT=10000                       # Render injects PORT automatically

Set environment variables via API:

curl -X PUT "https://api.render.com/v1/services/$SERVICE_ID/env-vars" \
  -H "Authorization: Bearer $RENDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"key":"MY_VAR","value":"my-value"}]'

Automation Workflows

Claude Code Slash Command: Service Health Check

.claude/commands/render-health.md:

Check the health of all Render services.

Use the Render MCP tool `list_services` to get all services and their status. 
For any service that is NOT "live", use `get_logs` to fetch recent logs and diagnose the issue.
Provide a summary table of service name, status, and any detected errors.

Usage: /project:render-health

GitHub Actions: Deploy after Tests Pass

# .github/workflows/render-deploy.yml
name: Deploy to Render
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Render deploy
        env:
          RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
          SERVICE_ID: ${{ secrets.RENDER_SERVICE_ID }}
        run: |
          curl -X POST "https://api.render.com/v1/services/$SERVICE_ID/deploys" \
            -H "Authorization: Bearer $RENDER_API_KEY" \
            -H "Content-Type: application/json" \
            -d '{"clearCache":"do_not_clear"}'

      - name: Wait for deploy and check status
        env:
          RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
          SERVICE_ID: ${{ secrets.RENDER_SERVICE_ID }}
        run: |
          for i in {1..20}; do
            status=$(curl -s "https://api.render.com/v1/services/$SERVICE_ID/deploys?limit=1" \
              -H "Authorization: Bearer $RENDER_API_KEY" | jq -r '.[0].deploy.status')
            echo "Status: $status"
            if [ "$status" = "live" ]; then echo "Deploy succeeded!"; exit 0; fi
            if [ "$status" = "deactivated" ]; then echo "Deploy failed!"; exit 1; fi
            sleep 15
          done
          echo "Timeout waiting for deploy"; exit 1

Common Use Cases

Use Case Approach
Inspect failing service MCP logs + metrics tools
Query production DB MCP read-only SQL query on a Postgres database
Create new service MCP create (web / static / cron / Postgres / Key Value) or REST API POST
Create a worker / private service REST API POST or render.yaml (not supported by MCP)
Deploy on merge GitHub Actions + REST API deploys endpoint, or MCP trigger-deploy
Env var management MCP update-env-vars or REST API PUT
Monitor resource usage MCP metrics tools

Troubleshooting

Issue Fix
Service stuck in "building" Check get_logs for build errors
Port connection refused Ensure app listens on process.env.PORT
503 on requests Service may be suspended (free tier)
Deploy not triggering Render auto-deploys on git push — check webhook in dashboard
API key invalid Generate a new key in dashboard → Settings → API Keys
Database connection failed Check DATABASE_URL env var; allow external connections in DB settings

Official docs:

Resend Integration Guide

Technology: resend · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/resend

Insight:

Resend is transactional email with React Email templates — receipts, resets, notifications. Email is a weak primary channel for many African users (unreliable inboxes), so treat it as secondary to SMS (Africa's Talking) and WhatsApp. Verify your sending domain (SPF/DKIM) to stay out of spam.

██████╗ ███████╗███████╗███████╗███╗   ██╗██████╗
██╔══██╗██╔════╝██╔════╝██╔════╝████╗  ██║██╔══██╗
██████╔╝█████╗  ███████╗█████╗  ██╔██╗ ██║██║  ██║
██╔══██╗██╔══╝  ╚════██║██╔══╝  ██║╚██╗██║██║  ██║
██║  ██║███████╗███████║███████╗██║ ╚████║██████╔╝
╚═╝  ╚═╝╚══════╝╚══════╝╚══════╝╚═╝  ╚═══╝╚═════╝

Resend Integration Guide

Focus: Transactional email delivery for codeAmani products — send emails programmatically using React Email templates and the Resend API.

Overview

Resend is a developer-first transactional email platform. Built by the team behind React Email, it provides a clean SDK and native support for rendering React components as email HTML. Used in codeAmani products for auth notifications, payment receipts, onboarding flows, and system alerts.

Here is the core flow at a glance — your app hands off to Resend, and delivery events flow back to you via webhooks:

flowchart LR
  A["Your App"] -->|"render React Email"| B["HTML"]
  B --> C["resend.emails.send"]
  C --> D["Resend API"]
  D --> E["Recipient inbox"]
  D -->|"delivery events"| F["Resend Webhook"]
  F -->|"email.delivered / bounced / complained"| G["Your webhook route"]
  G --> H["Update DB"]

Official Documentation

Resource URL
Resend Docs https://resend.com/docs
Node.js SDK https://github.com/resend/resend-node
React Email https://react.email/docs
API Reference https://resend.com/docs/api-reference
Webhooks https://resend.com/docs/dashboard/webhooks/introduction

SDK Setup

npm install resend

Current major: Resend Node SDK v6 (resend@6.x). A first-party Python SDK (resend on PyPI, v2.x) exists too, but codeAmani products are Node/Next.js.

Initialize Client

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

Core Patterns

Send a Simple Email

// app/api/email/send/route.ts
import { Resend } from "resend";
import { NextRequest } from "next/server";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: NextRequest) {
  const { to, subject, html } = await req.json();

  const { data, error } = await resend.emails.send({
    from: "codeAmani Labs <no-reply@codeamanilabs.com>",
    to,
    subject,
    html,
  });

  if (error) {
    return Response.json({ error }, { status: 500 });
  }

  return Response.json({ id: data?.id });
}

React Email Templates

npm install react-email react react-dom
# Optional dev-only preview UI (current manual setup):
npm install -D @react-email/ui

React Email 6.x unified the packages. Components and the render / pretty utilities now import from the single react-email package. The old @react-email/components and @react-email/render packages are deprecated — if you see import ... from "@react-email/components", migrate it to react-email.

// emails/WelcomeEmail.tsx
import {
  Body, Button, Container, Head, Heading,
  Html, Preview, Section, Text,
} from "react-email";

interface WelcomeEmailProps {
  userName: string;
  dashboardUrl: string;
}

export function WelcomeEmail({ userName, dashboardUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to codeAmani Labs</Preview>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container style={{ margin: "0 auto", padding: "20px", maxWidth: "580px" }}>
          <Heading>Welcome, {userName}!</Heading>
          <Text>Your account is ready. Click below to get started.</Text>
          <Section style={{ textAlign: "center", margin: "32px 0" }}>
            <Button
              href={dashboardUrl}
              style={{ background: "#16a34a", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}
            >
              Open Dashboard
            </Button>
          </Section>
          <Text style={{ color: "#6b7280", fontSize: "12px" }}>
            Powered by codeAmani Labs
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Send Using React Template

import { render } from "react-email";
import { WelcomeEmail } from "@/emails/WelcomeEmail";

// render() is async in React Email 6.x — always await it.
const html = await render(
  <WelcomeEmail
    userName={user.firstName}
    dashboardUrl={`${process.env.APP_URL}/dashboard`}
  />
);

await resend.emails.send({
  from: "codeAmani Labs <no-reply@codeamanilabs.com>",
  to: user.email,
  subject: "Welcome to codeAmani Labs",
  html,
});

Batch Send

await resend.batch.send([
  {
    from: "alerts@codeamanilabs.com",
    to: "user1@example.com",
    subject: "Payment Received",
    html: receipt1Html,
  },
  {
    from: "alerts@codeamanilabs.com",
    to: "user2@example.com",
    subject: "Payment Received",
    html: receipt2Html,
  },
]);

Clerk Webhook → Welcome Email Pattern

Trigger welcome emails automatically when Clerk creates a user:

// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { Resend } from "resend";
import { render } from "react-email";
import { WelcomeEmail } from "@/emails/WelcomeEmail";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: Request) {
  const body = await req.text();
  const svix_id = req.headers.get("svix-id")!;
  const svix_timestamp = req.headers.get("svix-timestamp")!;
  const svix_signature = req.headers.get("svix-signature")!;

  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
  const event = wh.verify(body, { "svix-id": svix_id, "svix-timestamp": svix_timestamp, "svix-signature": svix_signature }) as { type: string; data: { email_addresses: { email_address: string }[]; first_name: string } };

  if (event.type === "user.created") {
    const email = event.data.email_addresses[0].email_address;
    const html = await render(<WelcomeEmail userName={event.data.first_name} dashboardUrl={`${process.env.APP_URL}/dashboard`} />);

    await resend.emails.send({
      from: "codeAmani Labs <welcome@codeamanilabs.com>",
      to: email,
      subject: "Welcome to codeAmani Labs",
      html,
    });
  }

  return new Response("OK");
}

M-Pesa Receipt Emails

When a Daraja STK Push succeeds, the M-Pesa callback delivers the MpesaReceiptNumber, amount, and transaction date. Email a receipt as a secondary confirmation — the SMS from Safaricom is the user's primary proof, so never block the callback on the email send. Email here is a nicety (a paper trail), not the source of truth.

Here is the flow from a successful payment to a sent receipt:

sequenceDiagram
  participant D as "Daraja"
  participant CB as "Callback route"
  participant DB as "Database"
  participant R as "Resend"
  D->>CB: "STK callback · ResultCode 0"
  CB->>DB: "upsert by CheckoutRequestID"
  DB-->>CB: "inserted · receiptEmailSent false"
  CB-->>D: "200 OK immediately"
  CB->>R: "send PaymentReceipt · async"
  R-->>CB: "email id"
  CB->>DB: "set receiptEmailSent true"

PaymentReceipt Template

Amount is rendered with the KES prefix (Daraja amounts are integer KES — no decimals). The MpesaReceiptNumber is the canonical reference users quote in support.

// emails/PaymentReceipt.tsx
import {
  Body, Container, Head, Heading, Hr,
  Html, Preview, Row, Column, Section, Text,
} from "react-email";

interface PaymentReceiptProps {
  customerName: string;
  amount: number;            // integer KES
  mpesaReceiptNumber: string;
  transactionDate: string;   // already formatted for display (EAT)
  description: string;
}

export function PaymentReceipt({
  customerName,
  amount,
  mpesaReceiptNumber,
  transactionDate,
  description,
}: PaymentReceiptProps) {
  return (
    <Html>
      <Head />
      <Preview>Payment received — KES {amount.toLocaleString("en-KE")}</Preview>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container style={{ margin: "0 auto", padding: "20px", maxWidth: "580px" }}>
          <Heading>Payment received</Heading>
          <Text>Hi {customerName}, your M-Pesa payment was successful.</Text>

          <Section style={{ background: "#fff", borderRadius: "8px", padding: "20px", marginTop: "16px" }}>
            <Row>
              <Column style={{ color: "#6b7280" }}>Amount</Column>
              <Column style={{ textAlign: "right", fontWeight: 700 }}>
                KES {amount.toLocaleString("en-KE")}
              </Column>
            </Row>
            <Hr style={{ borderColor: "#e5e7eb", margin: "12px 0" }} />
            <Row>
              <Column style={{ color: "#6b7280" }}>M-Pesa receipt</Column>
              <Column style={{ textAlign: "right" }}>{mpesaReceiptNumber}</Column>
            </Row>
            <Hr style={{ borderColor: "#e5e7eb", margin: "12px 0" }} />
            <Row>
              <Column style={{ color: "#6b7280" }}>Date</Column>
              <Column style={{ textAlign: "right" }}>{transactionDate}</Column>
            </Row>
            <Hr style={{ borderColor: "#e5e7eb", margin: "12px 0" }} />
            <Row>
              <Column style={{ color: "#6b7280" }}>For</Column>
              <Column style={{ textAlign: "right" }}>{description}</Column>
            </Row>
          </Section>

          <Text style={{ color: "#6b7280", fontSize: "12px", marginTop: "16px" }}>
            Keep this receipt for your records. Powered by codeAmani Labs.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Send on the Daraja Callback

Pass the component directly via the react property — the Resend SDK renders it to HTML for you, so no manual render() call is needed. Pass it as a function call (PaymentReceipt({ ... })), not as JSX, in a .ts route handler.

// app/api/mpesa/callback/route.ts
import { Resend } from "resend";
import { PaymentReceipt } from "@/emails/PaymentReceipt";
import { NextRequest } from "next/server";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: NextRequest) {
  const body = await req.json();
  const cb = body.Body.stkCallback;

  // Always ACK Daraja fast — do not block on DB or email work
  if (cb.ResultCode !== 0) {
    // Payment failed / cancelled — record and return
    return Response.json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  // Pull metadata items by Name (order is not guaranteed)
  const items: { Name: string; Value: string | number }[] =
    cb.CallbackMetadata.Item;
  const get = (name: string) => items.find((i) => i.Name === name)?.Value;

  const amount = Number(get("Amount"));
  const mpesaReceiptNumber = String(get("MpesaReceiptNumber"));
  const checkoutRequestId = cb.CheckoutRequestID;

  // Idempotency: only the FIRST processing of this CheckoutRequestID
  // should send the receipt. Daraja can retry the callback.
  const txn = await markPaidIfNew(checkoutRequestId, {
    amount,
    mpesaReceiptNumber,
  });

  if (txn.firstTime && txn.customerEmail) {
    // Fire-and-forget: never let a Resend error fail the callback ACK
    sendReceipt(txn).catch((err) => logError("receipt-email", err));
  }

  return Response.json({ ResultCode: 0, ResultDesc: "Accepted" });
}

async function sendReceipt(txn: {
  customerName: string;
  customerEmail: string;
  amount: number;
  mpesaReceiptNumber: string;
  transactionDate: string;
  description: string;
}) {
  const { error } = await resend.emails.send({
    from: "codeAmani Labs <receipts@codeamanilabs.com>",
    to: txn.customerEmail,
    subject: `Payment received — KES ${txn.amount.toLocaleString("en-KE")}`,
    react: PaymentReceipt({
      customerName: txn.customerName,
      amount: txn.amount,
      mpesaReceiptNumber: txn.mpesaReceiptNumber,
      transactionDate: txn.transactionDate,
      description: txn.description,
    }),
  });
  if (error) throw error;
}

Gotcha — idempotent send, non-blocking ACK. Daraja may deliver the same callback more than once. Gate the email behind a "first time we marked this CheckoutRequestID paid" check (markPaidIfNew returns firstTime) so a retry never double-sends a receipt. And send fire-and-forget (.catch(...)) — the route must return ResultCode 0 to Daraja promptly regardless of whether Resend is slow or down. A failed receipt email must never turn a successful payment into a failed-looking callback.

Belt-and-suspenders. Resend also supports a native idempotency key — pass { idempotencyKey: 'receipt/' + checkoutRequestId } as the second argument to emails.send. Resend dedupes identical requests for 24 hours, so even if your app-level gate has a race, Resend will not send the same receipt twice within the window. Keys can be up to 256 chars; the recommended format is <event-type>/<entity-id>.


Webhooks (Delivery Events)

Resend sends delivery status events over Svix-signed webhooks. Verify with the SDK's built-in resend.webhooks.verify() — no separate svix npm package is needed (the Resend SDK wraps it). Pass the raw request body (do not parse JSON first) and the three svix-* headers as { id, timestamp, signature }:

// app/api/webhooks/resend/route.ts
import { Resend } from "resend";
import { NextRequest, NextResponse } from "next/server";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: NextRequest) {
  const payload = await req.text(); // raw body — required
  const id = req.headers.get("svix-id");
  const timestamp = req.headers.get("svix-timestamp");
  const signature = req.headers.get("svix-signature");

  if (!id || !timestamp || !signature) {
    return new NextResponse("Missing headers", { status: 400 });
  }

  let event: ReturnType<typeof resend.webhooks.verify>;
  try {
    event = resend.webhooks.verify({
      payload,
      headers: { id, timestamp, signature },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
    });
  } catch {
    return new NextResponse("Invalid webhook", { status: 400 });
  }

  switch (event.type) {
    case "email.delivered":
      // Mark as delivered in DB
      break;
    case "email.bounced":
      // Handle bounce — suppress the address
      break;
    case "email.complained":
      // Handle spam complaint — unsubscribe
      break;
  }

  return new NextResponse("OK");
}

Prefer the built-in verifier above. Verifying with the raw svix Webhook class (new Webhook(secret).verify(body, { "svix-id": ... })) still works and is the documented fallback if you already depend on svix — but the built-in method keeps your dependency surface smaller. Event types: email.sent, email.delivered, email.delivery_delayed, email.opened, email.clicked, email.bounced, email.complained.


Domain Setup

A quick map of getting your own domain verified — once these DNS records propagate, your custom from address works and you stay out of spam:

flowchart TD
  A["Add domain in Resend dashboard"] --> B["Resend provides DNS records"]
  B --> C["Add TXT DKIM record in Porkbun"]
  B --> D["Add TXT SPF record in Porkbun"]
  C --> E["Resend verifies DNS"]
  D --> E
  E --> F{"Verified?"}
  F -->|"yes"| G["Send from custom domain"]
  F -->|"no"| H["Wait for propagation, recheck"]
  H --> E

To send from your own domain, add DNS records via Porkbun:

# Records to add in Porkbun dashboard or via API:
# Type: TXT  Name: resend._domainkey  Value: (from Resend dashboard)
# Type: TXT  Name: @                  Value: v=spf1 include:amazonses.com ~all
# Type: MX   (if not already configured)

Preview Emails Locally

# Launch React Email preview server
npx react-email dev
# Open http://localhost:3000 to preview templates

Environment Variables

# Required
RESEND_API_KEY=re_...

# Optional
RESEND_WEBHOOK_SECRET=whsec_...

Common Use Cases

Use Case Template
Welcome / onboarding WelcomeEmail.tsx triggered by user.created
M-Pesa payment receipt PaymentReceipt.tsx triggered by M-Pesa callback
Subscription confirmation SubscriptionEmail.tsx triggered by Stripe webhook
Password reset Use Clerk's built-in email — only override for custom branding
System alerts Plain HTML — no React template needed

Troubleshooting

Issue Fix
Invalid API key Verify RESEND_API_KEY starts with re_
Emails going to spam Verify domain DNS records in Resend dashboard
from domain not verified Add and verify domain before using custom from
React Email not rendering Run npx react-email dev to preview template locally
Webhook 400 Verify with the SDK's resend.webhooks.verify(); pass the raw req.text() body (never parsed JSON) and the svix-* headers

Official docs:

Security Lab Integration Guide

Technology: security · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/security

Insight:

A security lab is an isolated, offline set of deliberately vulnerable targets you own — Juice Shop, DVWA, WebGoat, the MASTG crackmes — where you break something on purpose so you understand the defence that stops it. The trade-off is discipline: every offensive concept only earns its place when it ships back a countermeasure and a detection signal, and the lab must never have a route to a system you are not authorised to touch. For codeAmani this is where the CLAUDE.md security rules stop being a checklist — you feel why parameterised queries, webhook signature verification, RLS, and boundary sanitisation are non-negotiable because you watched each one fail.

███████╗███████╗ ██████╗██╗   ██╗██████╗ ██╗████████╗██╗   ██╗
██╔════╝██╔════╝██╔════╝██║   ██║██╔══██╗██║╚══██╔══╝╚██╗ ██╔╝
███████╗█████╗  ██║     ██║   ██║██████╔╝██║   ██║    ╚████╔╝
╚════██║██╔══╝  ██║     ██║   ██║██╔══██╗██║   ██║     ╚██╔╝
███████║███████╗╚██████╗╚██████╔╝██║  ██║██║   ██║      ██║
╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝  ╚═╝╚═╝   ╚═╝      ╚═╝

Security Lab Integration Guide

Focus: a defensive, offline lab of deliberately vulnerable targets you own, used to learn each attack class alongside the countermeasure that kills it and the signal that detects it — never as a playbook against real systems.

Read this before you install anything. Testing a computer system you do not own and are not authorised to test is a crime in essentially every jurisdiction codeAmani operates in — in the US under the Computer Fraud and Abuse Act (18 U.S.C. § 1030) and state equivalents, in Kenya under the Computer Misuse and Cybercrimes Act, 2018. "I was only learning" is not a defence, and neither is "the system was already broken." Intent does not create authorisation; a signed document does.

The rules this guide operates under, without exception:

  1. Own the target or hold written authorisation. Every host, app, container, and phone image in this lab is either something you installed on your own hardware or a purpose-built training platform whose terms of service explicitly invite testing (PortSwigger Web Security Academy, TryHackMe, Hack The Box, VulnHub images running locally). Nothing else. A public bug bounty programme's policy page is a form of authorisation — read it, and stay inside it.
  2. Written scope before any activity. A real engagement names the exact hosts, domains, IP ranges, and app versions in scope, and explicitly lists what is out of scope. Anything not named is out of scope. In your own lab, write the scope down anyway — it builds the habit and it stops "I'll just check whether the router does that too."
  3. Rules of engagement. Agree the test window, the rate limits, a named emergency contact on both sides, what happens if you find live customer data (stop, do not exfiltrate, report immediately), and the fact that you will not test availability. Data you encounter is handled under the client's data-protection obligations, not yours.
  4. No spillover. The lab has no default route to the internet and no route to production. Bind every target to 127.0.0.1 or a host-only network. A misconfigured docker run -p 3000:3000 publishes a knowingly-vulnerable app to your whole LAN — and, on a laptop with a public IP or an open Wi-Fi network, to strangers.
  5. Defence is the deliverable. A finding is not finished when you reproduce it. It is finished when you can state the root cause, the code-level fix, and the log line or rule that would have caught it. That is the entire point of this folder.

Out of scope for this guide, permanently: anything aimed at real or third-party systems, malware / ransomware / C2 development, denial-of-service techniques, detection or EDR evasion, and credential attacks at scale. Where a topic is dual-use, only the lab-isolated defensive framing appears here.

Overview

The lab is three things: targets (apps built to be broken), a proxy to watch traffic, and a notebook where each finding becomes a rule. Nothing exotic — the whole thing runs in Docker on a laptop.

The value is not the exploit. It is the loop: you see input reach a place it should never have reached, you find the line of code that let it, and you write the version that does not. After the fourth time you watch ' change the meaning of a SQL statement, you stop writing string-concatenated queries — permanently, without needing to be told.

Pick targets by what you want to learn:

Platform Runs Best for Cost
PortSwigger Web Security Academy hosted, per-lab the canonical, current explanation of every web class — free, no signup wall on the content free
OWASP Juice Shop Docker / npm a full modern JS SPA + API; realistic, gamified, ~100 challenges free, self-hosted
DVWA Docker Compose classic PHP app with a security level dial (low → impossible) — the single best vulnerable-vs-safe code diff free, self-hosted
OWASP WebGoat Docker lesson-by-lesson teaching with explanation built in; ships WebWolf as the "attacker's own server" free, self-hosted
TryHackMe hosted VMs guided rooms, gentle ramp, structured learning paths freemium
Hack The Box hosted VMs unguided machines; closest to a real engagement's ambiguity freemium
VulnHub download → local VM offline boot-to-root images you run on your own hypervisor free
OWASP MASTG crackmes Android / iOS mobile reverse-engineering and MASVS-RESILIENCE practice free
flowchart TB
  subgraph HOST["Your workstation"]
    PX["Intercepting proxy<br/>Burp Community · OWASP ZAP"]
  end
  subgraph LAB["Isolated lab — host-only, no default route"]
    J["OWASP Juice Shop<br/>127.0.0.1:3000"]
    D["DVWA<br/>127.0.0.1:4280"]
    W["WebGoat + WebWolf<br/>127.0.0.1:8080 / 9090"]
    M["Android emulator<br/>MASTG crackmes"]
  end
  subgraph OUT["Never a target"]
    P["Production · client systems"]
    T["Any third-party host"]
  end
  PX --> J
  PX --> D
  PX --> W
  PX --> M
  PX -.->|"no written authorisation"| P
  PX -.->|"illegal"| T
  J --> R["Root cause →<br/>countermeasure + detection"]
  D --> R
  W --> R
  M --> R
  R --> C["codeAmani CLAUDE.md rule<br/>parameterise · verify webhooks<br/>RLS · sanitise at boundaries"]

Official Documentation

Resource URL
PortSwigger Web Security Academy https://portswigger.net/web-security
Academy learning paths https://portswigger.net/web-security/learning-paths
OWASP Top 10 (current: 2025) https://owasp.org/Top10/
OWASP Cheat Sheet Series https://cheatsheetseries.owasp.org/
SQL Injection Prevention Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
OWASP Web Security Testing Guide (WSTG) https://owasp.org/www-project-web-security-testing-guide/
OWASP Juice Shop https://owasp.org/www-project-juice-shop/
DVWA (source) https://github.com/digininja/DVWA
OWASP WebGoat https://owasp.org/www-project-webgoat/
OWASP MAS (MASVS + MASTG) https://mas.owasp.org/
MASTG crackmes https://mas.owasp.org/crackmes/
MITRE ATT&CK (defensive mapping) https://attack.mitre.org/
NIST SP 800-115 (technical testing methodology) https://csrc.nist.gov/pubs/sp/800/115/final

Setup — build the lab

1. Start the targets

All three publish to 127.0.0.1 only. That prefix is not decoration — drop it and you are hosting a known-vulnerable app on your network.

# OWASP Juice Shop — modern SPA + REST API
docker run --rm -p 127.0.0.1:3000:3000 bkimminich/juice-shop

# OWASP WebGoat + WebWolf — guided lessons
docker run -it -p 127.0.0.1:8080:8080 -p 127.0.0.1:9090:9090 webgoat/webgoat

# DVWA — classic PHP, with the security-level dial
git clone https://github.com/digininja/DVWA.git
cd DVWA
docker compose up -d
# → http://localhost:4280   (default creds admin / password, then "Create / Reset Database")

Juice Shop lands on http://localhost:3000, WebGoat on http://localhost:8080/WebGoat/, WebWolf on http://localhost:9090/WebWolf/, DVWA on http://localhost:4280.

2. Add an intercepting proxy

An intercepting proxy is a debugging tool first and a testing tool second — the same thing you already reach for when a webhook body does not look like you expected.

# Burp Suite Community  — https://portswigger.net/burp/communitydownload
# OWASP ZAP (free, open source, scriptable, runs in CI)
docker run --rm -u zap -p 127.0.0.1:8080:8080 \
  ghcr.io/zaproxy/zaproxy:stable zap-webswing.sh

Point the browser at the proxy, install its CA certificate in a throwaway browser profile only, and never in your daily driver.

3. Isolate the network

Isolation is the load-bearing control, and it has its own guide — see sandbox/CLAUDE_CODE_INTEGRATION.md for VM/container isolation and networking/CLAUDE_CODE_INTEGRATION.md for the addressing and routing model. Minimum bar:

# A Docker network with no route off the host
docker network create --internal lab-net

# Verify a container on it genuinely cannot reach the internet
docker run --rm --network lab-net alpine sh -c "wget -qO- -T3 https://example.com || echo 'no egress — correct'"

For VM-based targets (VulnHub images especially — they are untrusted third-party disk images), use a host-only adapter, snapshot before first boot, and revert after. Never bridge a VulnHub image to your LAN.

4. Environment variables

The lab itself needs no secrets, which is the point. If you script anything against it, keep the values local and never point them at anything real:

# .env.local — lab only, never committed, never a production host
LAB_TARGET_URL=http://127.0.0.1:3000
LAB_PROXY_URL=http://127.0.0.1:8080

Never place a production URL, API key, or database connection string in a lab script. If a tool asks for a target and you have to think about whether the answer is allowed, the answer is no.


The attack classes — and what kills them

The current standard is the OWASP Top 10:2025. Two things changed that matter for how you plan a lab: SSRF was folded into A01 Broken Access Control, and Software Supply Chain Failures (A03) is now its own category — which is exactly why codeAmani's SLSA provenance policy exists (supply-chain/CLAUDE_CODE_INTEGRATION.md).

# (2025) Class What goes wrong Countermeasure How you detect it
A01 Broken Access Control (now includes SSRF) The server trusts a client-supplied identifier — ?orderId=1002, a JWT claim, a hidden field, a URL it is asked to fetch Deny by default; authorise server-side on every request against the session subject, never a request parameter. For SSRF: allowlist destination hosts, resolve-then-validate the IP, block link-local 169.254.169.254 and RFC1918 Log (subject, object, decision) on every access check; alert on a spike of denies from one session, or on outbound requests to internal ranges
A02 Security Misconfiguration Debug mode in prod, default creds, permissive CORS, verbose stack traces, a storage bucket left public Hardened build baseline in CI; explicit CORS origins; generic error responses; config diffed against a known-good template Config drift scanning; alert on Access-Control-Allow-Origin: * or a 500 that leaks a file path
A03 Software Supply Chain Failures A dependency, build system, or distribution channel is compromised — not just "old library" Pin and lock; npm audit signatures; SLSA Build L3 provenance on anything shipped; pin GitHub Actions to SHAs Dependency review in CI; alert on a lockfile change in a PR that touches no source
A04 Cryptographic Failures Secrets at rest in plaintext, home-rolled crypto, weak hashing, TLS not enforced Platform primitives only (argon2/bcrypt, AEAD ciphers); HSTS; secrets in a manager (Hazina), never in the repo Secret scanning (gitleaks) as a pre-push gate; TLS posture monitoring
A05 Injection (SQLi, command, XSS, template, LDAP) User input crosses from data into a grammar — SQL, a shell command, HTML, a template Parameterise. Prepared statements, execFileSync(cmd, [args]), contextual output encoding, DOMPurify for rendered HTML. Validate at the boundary as defence-in-depth, never as the primary control WAF / CRS rules; DB error-rate spikes; alert on queries whose shape changes (normalised statement fingerprint)
A06 Insecure Design The feature is unsafe as specified — no rate limit on OTP, no re-auth before an email change Threat-model at plan time; write abuse cases next to user stories Business-logic anomaly detection: N password resets/hour, refunds exceeding charges
A07 Authentication Failures Credential stuffing survivable, no MFA, weak session lifecycle, tokens that never expire MFA; rate limits + lockout on credential routes; rotate session ID on privilege change; short-lived tokens Alert on auth failure rate per account and per IP; impossible-travel; new-device sign-in
A08 Software or Data Integrity Failures Unsigned updates, insecure deserialisation, CI that trusts an unpinned action Signed artefacts + verification; never deserialise untrusted input into live objects; pin the pipeline Verify signatures at install; alert on unexpected artefact digests
A09 Security Logging and Alerting Failures The attack happened and nothing recorded it — or it recorded and nobody was paged Structured security events (authn, authz denials, admin actions, payment state changes) with a real alerting path This is the detection layer. Test it: run a lab attack and confirm something fires
A10 Mishandling of Exceptional Conditions Errors leak internals, or a failure path silently falls open (catch {} → grant access) Fail closed; generic client errors + detailed server-side logs; never swallow an exception on a security path Alert on error-rate spikes on auth/payment paths and on any authorisation code path reached via a catch block

Practise each row against a target: A01 on Juice Shop's basket and order endpoints, A05 on DVWA (flip the security level to read the fix diff), A07 and A09 on WebGoat's lesson sequence, and the whole set on the Academy's per-topic labs.


SQL injection in depth

SQL injection is the reference case because the mechanism generalises to every other injection class — and because the fix is one line.

The mechanism

A SQL statement has two things in it: grammar (SELECT, WHERE, ', --) and data (the phone number a user typed). String concatenation destroys that boundary — the user's text is parsed as grammar. A single ' closes the string literal early, and everything after it is executed as SQL.

// ❌ VULNERABLE — the input becomes part of the query's grammar
const phone = req.query.phone as string;
const rows = await db.query(
  `SELECT id, name, phone FROM riders WHERE phone = '${phone}'`
);

With phone = 254712000000 the database parses ... WHERE phone = '254712000000'. With phone = ' OR '1'='1 it parses ... WHERE phone = '' OR '1'='1' — a tautology, and the endpoint returns every rider. The attacker did not "guess a password"; they rewrote the query.

The fix: parameterise

A parameterised (prepared) statement sends the query text and the values to the database as separate things. The parser sees the statement first and finalises the grammar; values are then bound to placeholders. A ' inside a bound value is a ' character in a string — it can no longer become punctuation.

// ✅ SAFE — node-postgres / Neon: $1 placeholder, values in an array
const rows = await db.query(
  "SELECT id, name, phone FROM riders WHERE phone = $1",
  [phone]
);
// ✅ SAFE — postgres.js tagged template: interpolations are parameterised, not concatenated
const rows = await sql`SELECT id, name, phone FROM riders WHERE phone = ${phone}`;
// ✅ SAFE — Supabase / PostgREST: the filter builder parameterises for you
const { data, error } = await supabase
  .from("riders")
  .select("id, name, phone")
  .eq("phone", phone);

Escaping is not the fix. Hand-written quote-doubling, blocklists of the word UNION, and "strip the apostrophes" all fail against numeric contexts, second-order injection (safe on write, concatenated on read), encoding tricks, and the next database driver you swap in. Parameterise.

The parameterisation footguns

Placeholders bind values, never identifiers or keywords. Anywhere the query shape is dynamic, you cannot parameterise — so you must map through an allowlist:

// ❌ VULNERABLE — sort column concatenated straight in
const rows = await db.query(`SELECT * FROM deliveries ORDER BY ${req.query.sort}`);

// ✅ SAFE — allowlist maps an opaque token to a literal you wrote
const SORTS = { newest: "created_at DESC", fare: "fare_kes DESC" } as const;
const orderBy = SORTS[req.query.sort as keyof typeof SORTS] ?? SORTS.newest;
const rows = await db.query(`SELECT * FROM deliveries ORDER BY ${orderBy}`);

Every ORM keeps a raw escape hatch, and every one of them is the place SQLi comes back: Prisma's $queryRawUnsafe, Drizzle's sql.raw(), Knex's knex.raw() with template interpolation, TypeORM's query(). Grep for them in review. The safe raw form always takes values separately:

// ❌  prisma.$queryRawUnsafe(`SELECT * FROM riders WHERE phone = '${phone}'`)
// ✅  prisma.$queryRaw`SELECT * FROM riders WHERE phone = ${phone}`   // tagged template = parameterised

The layers behind it

Parameterisation is the control. These reduce the blast radius when something else slips through:

Least-privilege database user. The app's role should not be able to read tables it never touches, and should not be able to change schema. A read-only reporting path gets its own role.

-- The application role: exactly the verbs it needs, on exactly the tables it needs
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE ROLE app_rw LOGIN PASSWORD :'app_password';
GRANT USAGE ON SCHEMA public TO app_rw;
GRANT SELECT, INSERT, UPDATE ON deliveries, riders TO app_rw;
GRANT SELECT ON fare_rates TO app_rw;
-- deliberately absent: DROP, CREATE, TRUNCATE, and any grant on audit_log or webhook_jobs

Row Level Security, on Supabase and on plain Postgres, turns "the query returned rows it should not have" into "the database refused." It is the last line that holds when application-layer authorisation has a bug — which is exactly the A01 failure mode.

ALTER TABLE deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY rider_reads_own ON deliveries
  FOR SELECT USING (rider_id = auth.uid());

Input validation at the boundary. Validate shape, type, and range with a schema at the edge of the system — every form, API route, and webhook handler. It catches a large class of nonsense early and it makes the intended domain explicit. It is defence-in-depth, never the primary control.

import { z } from "zod";

const RiderLookup = z.object({
  // 254XXXXXXXXX — the M-Pesa phone format from CLAUDE.md
  phone: z.string().regex(/^254\d{9}$/),
});

const parsed = RiderLookup.safeParse(await req.json());
if (!parsed.success) return Response.json({ error: "invalid request" }, { status: 400 });

A WAF (Cloudflare's managed rules, or the OWASP Core Rule Set in front of your own origin) buys time against automated scanning and mass exploitation of a freshly-published CVE. It is a speed bump on a determined, targeted attempt. Never let its presence justify a concatenated query.

Detecting it


Mobile — OWASP MASVS and MASTG

The mobile equivalent of the Top 10 is the OWASP Mobile Application Security project: MASVS (the requirements standard) and MASTG (the testing guide, plus the crackmes to practise on). The controls are organised into eight categories, each of which maps to a decision you make while writing an app:

MASVS category The defensive question
MASVS-STORAGE Is anything sensitive on disk, in a backup, in a log, or hardcoded in the package?
MASVS-CRYPTO Are keys in the platform keystore, and is the crypto the platform's, not yours?
MASVS-AUTH Is authorisation enforced server-side, with the device only presenting a credential?
MASVS-NETWORK Is TLS enforced, with no user-added CA trust, and validation never disabled "for debugging"?
MASVS-PLATFORM Are IPC surfaces (exported activities, intents, deep links, WebViews, pasteboard) locked down?
MASVS-CODE Are dependencies current, is untrusted input validated, is debug tooling stripped from release?
MASVS-RESILIENCE Does tampering/reverse engineering raise the cost — knowing it never makes the app safe?
MASVS-PRIVACY Is data minimised, disclosed, and does the user actually have control?

Current MASTG is v2, which introduced MASWE weakness IDs (MASWE-0001 …) alongside MASTG-TEST-*, MASTG-DEMO-*, and MASTG-TOOL-* identifiers — a demo per test, so you can see the finding reproduced and then fixed.

Practise on the official crackmes (https://mas.owasp.org/crackmes/) for Android and iOS, on an emulator or a dedicated wiped device. Never on a phone that holds real accounts.

The single most useful lesson the mobile lab teaches: the client is not a trust boundary. Anything the app can compute, an attacker with the binary can compute. Root/jailbreak detection, certificate pinning, and obfuscation (MASVS-RESILIENCE) raise cost — they do not create security. Every authorisation decision belongs on the server. This is the same rule as "middleware is a redirect optimisation, not an authorisation boundary" from better-auth/CLAUDE_CODE_INTEGRATION.md, wearing different clothes.


Desktop and OS hardening

The lab teaches the app layer; the host layer is where a compromise becomes permanent. A defensible baseline, per platform:

Platform Baseline that matters Official reference
Windows BitLocker on, Secure Boot + TPM, virtualisation-based security / Credential Guard, SmartScreen, Defender with tamper protection, standard (non-admin) daily account, Attack Surface Reduction rules https://learn.microsoft.com/en-us/windows/security/
macOS FileVault, System Integrity Protection left on, Gatekeeper + notarisation enforced, firewall on, standard account for daily use, Lockdown Mode where the threat model warrants https://support.apple.com/guide/security/welcome/web
Linux Full-disk encryption, automatic security updates, no password SSH (keys only) with root login disabled, host firewall default-deny inbound, SELinux/AppArmor enforcing, minimal installed surface https://ubuntu.com/security
iOS Current iOS, strong passcode + biometric, automatic updates, minimal profile trust (never install an unknown MDM/CA profile), Lockdown Mode for high-risk users https://support.apple.com/guide/security/welcome/web
Android Current Android + Play Protect, verified boot, sideloading off, per-app permission review, work profile to separate contexts, no root on a device holding real accounts https://source.android.com/docs/security

Cross-cutting, and worth more than any individual toggle: patch fast, run as a standard user, use a password manager plus phishing-resistant MFA (passkeys / hardware keys), full-disk encryption everywhere, and have a restore-tested backup. Ransomware defence is backup and recovery, not a product.

For developer machines specifically: keep the lab off the machine that holds production credentials. Lab VMs get snapshotted and reverted; VulnHub images are untrusted binaries from strangers and are treated as such. See sandbox/CLAUDE_CODE_INTEGRATION.md.


The loop: finding → fix → detection

Every lab session produces three artefacts, and stops being useful if it produces fewer:

  1. Root cause — the specific line or design decision, not the symptom. "The order endpoint reads orderId from the query string and never checks it against the session subject."
  2. Countermeasure — the diff. Parameterised query, server-side authorisation check, output encoding, signature verification. Written as code, not as advice.
  3. Detection — the log event and the alert that would have fired. If you cannot name it, you have found A09:2025 in your own stack.

Map the finding to MITRE ATT&CK (https://attack.mitre.org/) when it helps you talk to a security team, and score severity with CVSS (https://www.first.org/cvss/) when you need a shared vocabulary for prioritisation. Sigma (https://github.com/SigmaHQ/sigma) is a portable way to write the detection rule once.


codeAmani notes

This lab is where the CLAUDE.md security rules come from

Every rule in the workspace CLAUDE.md is the scar tissue of one of the classes above. The lab is how an engineer gets the instinct instead of the checklist:

Secrets and the lab

Kenya-targeted projects: the M-Pesa callback is hostile input

For boda-dispatch, duka-order-bot, and the rest of the Kenya-targeted builds, the highest-value application of this lab is the Daraja callback path, because it combines three classes at once:

Two more that are genuinely regional rather than forced: WhatsApp inbound messages are the same category of untrusted input as a form field — validate before they reach a query or a template. And on low-bandwidth Android, a tight Content-Security-Policy plus a small JS bundle is both a performance win and a real XSS mitigation, since CSP is what stops an injected script from executing at all.

Pair this guide with

The one-line summary

You break a target you own so that you never ship the bug — and the session is only finished when the finding has a diff and an alert attached to it.

Official docs:

SendGrid Integration Guide

Technology: sendgrid · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/sendgrid

Insight:

SendGrid is advertised on motionstackstudios.com, but the house default for transactional email is Resend + React Email (see the resend guide). Reach for SendGrid only on client projects that require it — an existing SendGrid account, high-volume marketing + transactional at scale, sub-user/multi-tenant sending, or consolidating onto a Twilio stack. Verify the sending domain (SPF/DKIM/DMARC) and verify the signed Event Webhook before trusting any event.

███████╗███████╗███╗   ██╗██████╗  ██████╗ ██████╗ ██╗██████╗
██╔════╝██╔════╝████╗  ██║██╔══██╗██╔════╝ ██╔══██╗██║██╔══██╗
███████╗█████╗  ██╔██╗ ██║██║  ██║██║  ███╗██████╔╝██║██║  ██║
╚════██║██╔══╝  ██║╚██╗██║██║  ██║██║   ██║██╔══██╗██║██║  ██║
███████║███████╗██║ ╚████║██████╔╝╚██████╔╝██║  ██║██║██████╔╝
╚══════╝╚══════╝╚═╝  ╚═══╝╚═════╝  ╚═════╝ ╚═╝  ╚═╝╚═╝╚═════╝

SendGrid Integration Guide

Focus: When and how codeAmani uses Twilio SendGrid for client projects that specifically require it — sending with the @sendgrid/mail SDK, dynamic templates, domain authentication (SPF/DKIM/DMARC) for deliverability, a signature-verified Event Webhook, suppression management, and inbound parse. The house default remains Resend + React Email.

Overview

SendGrid (Twilio SendGrid) is a high-volume email platform with a mature Web API v3, marketing campaigns, dynamic Handlebars templates, sub-user/multi-tenant sending, and a rich event-tracking pipeline. It is advertised as an email option on motionstackstudios.com.

For codeAmani's own products, transactional email is Resend + React Email — cleaner DX, React-rendered templates, fewer moving parts (see the resend guide). SendGrid earns a place only on client engagements that require it:

The core send path plus the event-feedback loop:

flowchart LR
  A["Your App"] -->|"templateId + dynamicTemplateData"| B["@sendgrid/mail"]
  B --> C["SendGrid Web API v3<br/>POST /v3/mail/send"]
  C --> D["Recipient inbox"]
  C -->|"delivery + engagement events"| E["Event Webhook<br/>(signed, batched)"]
  E -->|"X-Twilio-Email-Event-Webhook-Signature"| F["Your webhook route"]
  F -->|"verify ECDSA signature"| G{"Valid?"}
  G -->|"yes"| H["Update DB · suppress bounces"]
  G -->|"no"| I["403 reject"]

Check first. Before wiring SendGrid into a build, query the codeAmani-tech-stack MCP (search_guides / get_guide) and confirm the house Resend default genuinely can't serve the requirement. SendGrid adds vendor surface and a second email-deliverability footprint — prefer Resend unless the client specifically needs SendGrid.

Official Documentation

Resource URL
SendGrid Docs (root) https://www.twilio.com/docs/sendgrid
Mail Send — Web API v3 https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
Dynamic Templates https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
Event Webhook + signed events https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event
Domain Authentication (SPF/DKIM) https://www.twilio.com/docs/sendgrid/ui/account-and-settings/how-to-set-up-domain-authentication
Node.js SDK https://github.com/sendgrid/sendgrid-nodejs

SDK Setup

npm install @sendgrid/mail        # sending email
npm install @sendgrid/client      # raw Web API v3 (suppressions, templates, stats)
npm install @sendgrid/eventwebhook   # Event Webhook signature verification

Current major: the @sendgrid/* Node packages are on v8 (@sendgrid/mail@8, @sendgrid/client@8, @sendgrid/eventwebhook@8 at review). They ship from the one sendgrid-nodejs monorepo and version together — install the matching major across all three. v8 dropped support for old Node (needs a maintained Node runtime); the send / template / webhook APIs below are unchanged from v7. A first-party Python SDK (sendgrid on PyPI) exists too, but codeAmani products are Node/Next.js.

Initialize the Client

// lib/sendgrid.ts
import sgMail from "@sendgrid/mail";

if (!process.env.SENDGRID_API_KEY) {
  throw new Error("SENDGRID_API_KEY is not set");
}
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

export { sgMail };
export const FROM = process.env.SENDGRID_FROM_EMAIL!; // must be a verified sender / authenticated domain

Core Patterns

Send a Templated Email (Dynamic Templates)

SendGrid's dynamic templates are Handlebars templates created in the dashboard (Email API → Dynamic Templates). Each version compiles to HTML; you reference it by templateId (always starts with d-) and pass dynamicTemplateData. The subject and body live in the template, so you do not set subject/html here — the template owns them.

// app/api/email/welcome/route.ts
import { NextRequest } from "next/server";
import { sgMail, FROM } from "@/lib/sendgrid";

export async function POST(req: NextRequest) {
  const { to, userName, dashboardUrl } = await req.json();

  try {
    await sgMail.send({
      to,
      from: FROM,
      templateId: process.env.SENDGRID_WELCOME_TEMPLATE_ID!, // "d-..."
      dynamicTemplateData: {
        userName,
        dashboardUrl,
        year: new Date().getFullYear(),
      },
    });
    return Response.json({ ok: true });
  } catch (error: any) {
    // SendGrid surfaces field-level errors in error.response.body.errors
    const body = error?.response?.body;
    console.error("sendgrid error", body ?? error);
    return Response.json({ error: body?.errors ?? "send failed" }, { status: 502 });
  }
}

Send Plain HTML (no template)

For one-off system alerts where a dashboard template is overkill:

await sgMail.send({
  to: "ops@client.example",
  from: FROM,
  subject: "Nightly job failed",
  text: "The reconciliation job exited non-zero. Check the logs.",
  html: "<p>The reconciliation job exited non-zero. Check the logs.</p>",
});

Gotcha — verified sender required. The from address must be a Single Sender or, in production, an address on an authenticated domain (below). An unverified from returns 403 Forbidden with The from address does not match a verified Sender Identity.

Multiple Recipients Without Leaking the List

By default a to: [] array exposes every recipient to each other. Use isMultiple: true so SendGrid sends an individual message per recipient, or use personalizations for per-recipient template data:

await sgMail.send(
  {
    to: ["a@example.com", "b@example.com"],
    from: FROM,
    templateId: process.env.SENDGRID_DIGEST_TEMPLATE_ID!,
    dynamicTemplateData: { week: "2026-W25" },
  },
  /* isMultiple */ true,
);

Domain Authentication (SPF / DKIM / DMARC)

Deliverability is the whole game. A SendGrid send from an unauthenticated domain lands in spam or gets the via sendgrid.net "on behalf of" stamp. Authenticate the domain before sending anything real. codeAmani manages client DNS through Porkbun (see the porkbun-dns guide).

flowchart TD
  A["Sender Authentication →<br/>Authenticate Your Domain"] --> B["SendGrid returns 3 CNAME records<br/>(2 DKIM keys + 1 mail/return-path)"]
  B --> C["Add the CNAMEs in Porkbun"]
  C --> D["SendGrid 'Verify' — checks DNS"]
  D --> E{"Verified?"}
  E -->|"no"| F["Wait for propagation, recheck"]
  F --> D
  E -->|"yes"| G["Add DMARC TXT record at _dmarc"]
  G --> H["Send from your authenticated domain"]

Checklist:

  1. SendGrid → Settings → Sender Authentication → Authenticate Your Domain. Pick the DNS host (Porkbun / "Other") and the domain (e.g. mail.client.example). Disable link branding only if the client has a reason to.
  2. SendGrid issues three CNAME records — two DKIM signing keys (s1._domainkey, s2._domainkey) and a return-path / mail CNAME. SendGrid manages SPF for you behind the return-path CNAME, so you normally do not hand-author an SPF include.
  3. Add all three CNAMEs in Porkbun exactly as given (host + target), then click Verify in SendGrid.
  4. Add a DMARC policy yourself — SendGrid does not create it. Start in monitor mode, then tighten:
# Porkbun DNS records
# DKIM / return-path: add the 3 CNAMEs exactly as SendGrid lists them, e.g.
#   Type: CNAME  Host: s1._domainkey  Target: s1.domainkey.uXXXX.wlYYY.sendgrid.net
#   Type: CNAME  Host: s2._domainkey  Target: s2.domainkey.uXXXX.wlYYY.sendgrid.net
#   Type: CNAME  Host: em1234         Target: uXXXX.wlYYY.sendgrid.net
#
# DMARC (author this yourself — start at p=none and watch reports):
#   Type: TXT  Host: _dmarc  Value: v=DMARC1; p=none; rua=mailto:dmarc@client.example; fo=1

Gotcha — Porkbun and the apex. Add the CNAMEs on the subdomain host SendGrid specifies (often em####, s1._domainkey, s2._domainkey), not the apex. Porkbun won't let a CNAME coexist on a host that already has other records — give SendGrid its own subdomain. Once DMARC is at p=none and aligned reports look clean, move to p=quarantine then p=reject.


Event Webhook (Signed Event Verification)

SendGrid POSTs batched JSON arrays of delivery and engagement events (delivered, bounce, dropped, deferred, open, click, spamreport, unsubscribe, ...). Enable Signed Event Webhook Requests (Settings → Mail Settings → Event Webhook) and SendGrid signs each request with an ECDSA key; it gives you the Base64 public verification key. This is the SendGrid analogue of the Svix-signed Resend/Clerk webhooks in the webhooks and resend guides — never trust an unsigned event.

Two non-negotiables: verify against the raw request body (any reserialization breaks the signature), and pass the two headers via the SDK's EventWebhookHeader helpers.

// app/api/webhooks/sendgrid/route.ts  (Next.js App Router — Node runtime)
import { EventWebhook, EventWebhookHeader } from "@sendgrid/eventwebhook";
import { NextRequest } from "next/server";

export const runtime = "nodejs";        // ECDSA verify needs Node crypto, not edge
export const dynamic = "force-dynamic";

interface SgEvent {
  email: string;
  event:
    | "delivered" | "bounce" | "dropped" | "deferred"
    | "open" | "click" | "spamreport" | "unsubscribe";
  timestamp: number;
  sg_event_id: string;
  reason?: string;
  url?: string;
}

export async function POST(req: NextRequest) {
  // 1) Read the RAW body — do not JSON.parse before verifying.
  const rawBody = await req.text();

  const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
  const signature = req.headers.get(EventWebhookHeader.SIGNATURE());   // X-Twilio-Email-Event-Webhook-Signature
  const timestamp = req.headers.get(EventWebhookHeader.TIMESTAMP());   // X-Twilio-Email-Event-Webhook-Timestamp

  if (!publicKey || !signature || !timestamp) {
    return new Response("missing signature material", { status: 400 });
  }

  // 2) Verify the ECDSA signature over (timestamp + rawBody).
  const ew = new EventWebhook();
  const ecKey = ew.convertPublicKeyToECDSA(publicKey);
  const valid = ew.verifySignature(ecKey, rawBody, signature, timestamp);
  if (!valid) {
    return new Response("invalid signature", { status: 403 });
  }

  // 3) Now it's safe to parse and process the batch.
  const events: SgEvent[] = JSON.parse(rawBody);
  for (const e of events) {
    switch (e.event) {
      case "delivered":
        // mark delivered in DB
        break;
      case "bounce":
      case "dropped":
        // hard failure — add to your local suppression mirror, stop sending
        await suppressLocally(e.email, e.reason);
        break;
      case "spamreport":
      case "unsubscribe":
        // honor opt-out — never email again
        await markUnsubscribed(e.email);
        break;
      case "open":
      case "click":
        // engagement analytics (e.url for clicks)
        break;
    }
  }

  // 4) ACK fast. SendGrid retries non-2xx; 2xx stops retries.
  return new Response(null, { status: 204 });
}

declare function suppressLocally(email: string, reason?: string): Promise<void>;
declare function markUnsubscribed(email: string): Promise<void>;

Gotcha — raw body + idempotency. In the App Router, await req.text() gives you the exact bytes SendGrid signed; calling req.json() first (or running through a body parser) will reserialize and the signature check fails with a valid request. SendGrid signs the payload including its trailing \r\n (and a \r\n after each event in a batch), so never .trim() the body before verifying — req.text() preserves those bytes for you, which is exactly why it works. Events arrive batched and can be redelivered, so dedupe on sg_event_id before acting, and ACK with 2xx quickly — slow handlers trigger SendGrid's retry storm.


Suppression Management

SendGrid maintains server-side suppression lists (bounces, blocks, spam reports, unsubscribes, invalid emails) and will not deliver to a suppressed address. Mirror these locally from the Event Webhook so your app never re-queues a dead address, and read/clear them via @sendgrid/client when a user genuinely re-opts-in.

// lib/sendgrid-suppressions.ts
import client from "@sendgrid/client";

client.setApiKey(process.env.SENDGRID_API_KEY!);

/** Remove an address from the global bounce list (e.g. after a typo fix). */
export async function clearBounce(email: string): Promise<void> {
  await client.request({
    method: "DELETE",
    url: `/v3/suppression/bounces/${encodeURIComponent(email)}`,
  });
}

/** Check whether an address is on the global unsubscribe list. */
export async function isUnsubscribed(email: string): Promise<boolean> {
  const [, body] = await client.request({
    method: "GET",
    url: "/v3/suppression/unsubscribes",
    qs: { email },
  });
  return Array.isArray(body) && body.length > 0;
}

Treat SendGrid's suppression list as the source of truth and your local mirror as a fast pre-check. Never strip an unsubscribe — it's a legal (CAN-SPAM) and reputation obligation.


Inbound Parse

Inbound Parse turns received email into a webhook POST (multipart form) to your app — useful for reply-to-ticket flows, document intake by email, or +tag routing.

  1. Add an MX record for the receiving subdomain in Porkbun: Type: MX Host: parse Priority: 10 Target: mx.sendgrid.net.
  2. SendGrid → Settings → Inbound Parse → add host parse.client.example → destination URL https://app.client.example/api/inbound.
// app/api/inbound/route.ts  — SendGrid posts multipart/form-data
import { NextRequest } from "next/server";

export const runtime = "nodejs";

export async function POST(req: NextRequest) {
  const form = await req.formData();
  const from = String(form.get("from") ?? "");
  const subject = String(form.get("subject") ?? "");
  const text = String(form.get("text") ?? "");      // plain-text body
  const attachmentCount = Number(form.get("attachments") ?? 0);

  // route by +tag, file a ticket, persist attachments, etc.
  await handleInbound({ from, subject, text, attachmentCount });

  return new Response(null, { status: 200 });
}

declare function handleInbound(msg: {
  from: string; subject: string; text: string; attachmentCount: number;
}): Promise<void>;

Inbound Parse has no signed-event mechanism like the Event Webhook. Guard the endpoint with a hard-to-guess path plus a shared secret in the URL, validate the from/SPF if it matters, and treat all content as untrusted user input.


SendGrid vs the House Resend Default

Consult the codeAmani-tech-stack MCP before choosing. The house default is Resend + React Email for transactional email; SendGrid is a client-driven exception, not a default.

Concern House default (Resend) SendGrid (client-required)
Primary use codeAmani products, transactional Client owns SendGrid / high-volume / Twilio-stack
Templates React Email components (.tsx) Dashboard Handlebars dynamic templates (d-...)
SDK resend @sendgrid/mail + @sendgrid/client
Webhook signing Svix signature ECDSA via @sendgrid/eventwebhook
Marketing campaigns Not the focus First-class (lists, segments, dedicated IPs)
Multi-tenant sending Single account Sub-users with isolated reputation/stats
When to pick Default — start here Only when a client requires SendGrid specifically

Environment Variables

# Required
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxxx   # create as "Restricted Access" (Mail Send only) where possible
SENDGRID_FROM_EMAIL=no-reply@mail.client.example   # verified sender / authenticated domain

# Event Webhook (Base64 public key from Mail Settings → Event Webhook → Signed)
SENDGRID_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...

# Dynamic template IDs (always start with d-)
SENDGRID_WELCOME_TEMPLATE_ID=d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SENDGRID_DIGEST_TEMPLATE_ID=d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Add these to ENV_MASTER.md and each project's .env.example. Use a Restricted Access API key scoped to Mail Send for the app; reserve a full-access key for admin scripts only. The API key is server-side only — never ship it to the client bundle.


Common Use Cases

Use Case Approach
Default transactional email Resend + React Email (house default) — use SendGrid only if the client requires it
Templated transactional send sgMail.send with templateId + dynamicTemplateData
One-off system alert sgMail.send with subject/text/html, no template
Per-recipient batch personalizations or isMultiple: true to avoid leaking the list
Delivery / bounce tracking Signed Event Webhook → verify → update DB + suppress
Honor opt-outs Mirror suppressions locally; check before every send
Reply / intake by email Inbound Parse → MX record + multipart webhook
Deliverability Authenticate the domain (DKIM CNAMEs) + add DMARC
Multi-tenant client SendGrid sub-users, one per tenant

Troubleshooting

Issue Fix
401 Unauthorized SENDGRID_API_KEY missing/typo, or the key was revoked — recreate it
403 Forbidden on send from is not a verified Sender / authenticated domain — verify it first
Emails land in spam / "via sendgrid.net" Domain not authenticated — add the DKIM CNAMEs and DMARC, then re-verify
Webhook always returns 403 You parsed the body before verifying — verify against the raw req.text(); confirm SENDGRID_WEBHOOK_PUBLIC_KEY matches the dashboard key
Webhook signature valid locally, fails in prod A proxy/body-parser reserialized the body — ensure raw bytes reach the verifier (Node runtime, no JSON middleware)
Duplicate event processing Dedupe on sg_event_id; SendGrid batches and can redeliver
Template renders blank vars dynamicTemplateData keys must match the Handlebars {{vars}}; subject lives in the template, not the send call
Recipients see each other Use isMultiple: true or personalizations, not a bare to: []
Mail to an address silently never arrives It's on a SendGrid suppression list — check /v3/suppression/* and clear if appropriate
Inbound Parse never fires MX record for the parse subdomain missing/incorrect in Porkbun (mx.sendgrid.net, priority 10)

Official docs:

Sentry Integration Guide

Technology: sentry · Category: monitoring · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/sentry

Insight:

Sentry is the error/issue layer — wire it in early so production failures (especially M-Pesa callback edge cases) surface with stack traces instead of silent drops. Verify Sentry webhooks with Svix, and scrub PII/secrets from event payloads before they leave your servers.

███████╗███████╗███╗   ██╗████████╗██████╗ ██╗   ██╗
██╔════╝██╔════╝████╗  ██║╚══██╔══╝██╔══██╗╚██╗ ██╔╝
███████╗█████╗  ██╔██╗ ██║   ██║   ██████╔╝ ╚████╔╝
╚════██║██╔══╝  ██║╚██╗██║   ██║   ██╔══██╗  ╚██╔╝
███████║███████╗██║ ╚████║   ██║   ██║  ██║   ██║
╚══════╝╚══════╝╚═╝  ╚═══╝   ╚═╝   ╚═╝  ╚═╝   ╚═╝

Sentry Integration Guide

Focus: Error monitoring, issue triage, and automated debugging workflows from Claude Code using the official Sentry MCP server and sentry-cli.

Overview

Sentry is the leading error and performance monitoring platform. Its official MCP server gives Claude Code access to your issues, traces, spans, logs, and Seer (AI root-cause analysis) — and, as of the current server, it can also take triage actions (resolve/assign issues, create projects/DSNs, add notes). That enables automated triage, log-driven debugging, and performance investigation without leaving a coding session. Combined with sentry-cli for release management and source maps, it closes the loop between deploy and error resolution.

Here is the core loop at a glance — from a failure in your app all the way to a proposed fix in Claude Code:

flowchart LR
    A["App throws error<br/>e.g. M-Pesa callback"] --> B["Sentry SDK<br/>captures exception"]
    B --> C["Sentry platform<br/>issue + stack trace"]
    C --> D["Seer AI<br/>root-cause analysis"]
    C --> E["MCP server<br/>read + triage actions"]
    D --> E
    E --> F["Claude Code<br/>triage + propose fix"]

Official Documentation

Resource URL
Sentry Docs https://docs.sentry.io
Sentry MCP Server https://mcp.sentry.dev/
sentry-cli Reference https://docs.sentry.io/cli/
Performance Tutorial https://sentry.io/cookbook/performance-bot-sentry-claude/
Source Maps https://docs.sentry.io/platforms/javascript/sourcemaps/

MCP Server Setup

Official Sentry MCP Server (Remote, OAuth)

Sentry hosts its MCP server at https://mcp.sentry.dev/mcp. Authentication is via OAuth — nothing to install.

# Add Sentry MCP to Claude Code
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp

# Optionally scope the connection to one org/project so tools default to it
claude mcp add --transport http sentry \
  https://mcp.sentry.dev/mcp/{organizationSlug}/{projectSlug}

Then run /mcp inside Claude Code to authenticate with your Sentry organization via OAuth. Every connection uses OAuth; the first request triggers the browser auth flow.

.mcp.json Configuration (Token-based)

{
  "mcpServers": {
    "sentry": {
      "type": "http",
      "url": "https://mcp.sentry.dev/mcp",
      "headers": {
        "Authorization": "Bearer ${SENTRY_AUTH_TOKEN}"
      }
    }
  }
}

Get your auth token at: https://sentry.io/settings/account/api/auth-tokens/

Available MCP Tools

The MCP server now exposes ~50 tools. The old flat list_*/get_* names have been renamed and expanded; the ones you reach for most from a coding session:

Tool Description
find_projects / find_organizations / find_teams Discover org, project, and team slugs
search_issues Natural-language / query search across issues
get_issue_details Full issue detail incl. stack trace, culprit, counts
get_event_stacktrace / get_issue_breadcrumbs Deep-dive a single event
search_events Query events, errors, spans, and logs (replaces the old get_events/get_performance)
get_trace_details / get_span_details Distributed trace + span investigation (replaces get_trace/get_spans)
analyze_issue_with_seer Trigger Seer AI root-cause + fix analysis (replaces get_issue_summary)
search_docs / get_doc Search and read Sentry's own docs
whoami Confirm the authenticated account

Not read-only anymore. The MCP server now includes write/mutation tools — update_issue (resolve/assign/set status), add_issue_note, create_project, update_project, create_dsn/update_dsn, create_team, create_uptime_monitor, and analyze_issue_with_seer. Treat it as a full control surface, not just a viewer. There are also discovery meta-tools (search_sentry_tools, execute_sentry_tool) for the larger catalog. Guard the OAuth grant / token scopes accordingly (see Troubleshooting).

Claude Code Plugin Integration

Sentry also ships an official Claude Code plugin (skills such as sentry-debug-issue, sentry-instrument, sentry-setup-releases, sentry-fix-stack-traces, sentry-create-alert) that wraps these MCP tools into guided workflows:

# With the sentry plugin + MCP connected, just ask in natural language:
#   "Check Sentry for recent auth errors and propose a fix"
# Claude delegates to the Sentry MCP tools (search_issues → get_issue_details →
# analyze_issue_with_seer) automatically.

CLI Integration (sentry-cli)

Installation

# npm
npm install -g @sentry/cli

# macOS (brew)
brew install getsentry/tools/sentry-cli

# curl (Linux)
curl -sL https://sentry.io/get-cli/ | bash

Authentication

sentry-cli login
# Or use environment variables:
export SENTRY_AUTH_TOKEN=...
export SENTRY_ORG=my-org
export SENTRY_PROJECT=my-project

Key Commands

These commands chain into the release and source-map flow below — finalize a release so future errors map back to readable code:

flowchart TD
    A["releases new<br/>v1.2.3"] --> B["set-commits<br/>--auto"]
    B --> C["files upload-sourcemaps<br/>./dist"]
    C --> D["releases finalize<br/>v1.2.3"]
    D --> E["releases deploys<br/>--env production"]
    E --> F["Errors resolve to<br/>original source lines"]
# Create a release
sentry-cli releases new v1.2.3

# Associate commits with a release
sentry-cli releases set-commits v1.2.3 --auto

# Upload source maps
sentry-cli releases files v1.2.3 upload-sourcemaps ./dist \
  --url-prefix "~/static/js"

# Finalize the release (marks it as deployed)
sentry-cli releases finalize v1.2.3

# Create a deploy record
sentry-cli releases deploys v1.2.3 new \
  --env production \
  --name "GitHub Actions Deploy"

# List projects
sentry-cli projects list

# List issues (basic)
sentry-cli issues list --project my-project --status unresolved

# Resolve an issue
sentry-cli issues resolve ISSUE_ID

SDK Integration

JavaScript / TypeScript

npm install @sentry/nextjs  # currently v10.x — or @sentry/node, @sentry/react, etc.

Next.js setup (v9/v10): init lives in four files — instrumentation.ts (server/edge + onRequestError), instrumentation-client.ts (browser + onRouterTransitionStart), sentry.server.config.ts, and sentry.edge.config.ts — plus withSentryConfig wrapping next.config.ts. The standalone sentry.client.config.ts is gone; client init moved to instrumentation-client.ts. See PATTERNS.md for the full recipe. The server config below is what instrumentation.ts imports for the Node.js runtime.

sentry.server.config.ts:

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  release: process.env.NEXT_PUBLIC_APP_VERSION,
  tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
  integrations: [
    Sentry.prismaIntegration(),  // auto-instrument Prisma
  ],
});

Capture custom errors with context

import * as Sentry from "@sentry/nextjs";

try {
  await processPayment(userId, amount);
} catch (error) {
  Sentry.withScope((scope) => {
    scope.setUser({ id: userId });
    scope.setTag("payment.amount", String(amount));
    scope.setLevel("error");
    Sentry.captureException(error);
  });
  throw error;
}

Scrubbing PII & secrets

Sentry never captures user IP or request headers/cookies by default — that behavior is gated behind sendDefaultPii, which defaults to false. Leave it off in production. For anything the SDK does capture (request bodies, query strings, exception values), use the beforeSend hook to redact M-Pesa phone numbers, OAuth tokens, and other secrets before the event leaves your server. beforeSend runs after all scope data is applied, so it's the last line of defense — return a modified event, or null to drop it entirely. Sentry also runs best-effort server-side scrubbing on ingest, but never rely on it alone for known-sensitive fields.

import * as Sentry from "@sentry/nextjs";

const REDACT = /(254\d{9})|(sntrys_[\w-]+)|(Bearer\s+[\w.-]+)/gi;

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  sendDefaultPii: false, // keep IPs/headers/cookies out of events (default)
  beforeSend(event) {
    // Drop user email; keep only a non-PII id for impact counts
    if (event.user) delete event.user.email;

    // Redact M-Pesa numbers and tokens from the exception message
    if (event.exception?.values) {
      for (const ex of event.exception.values) {
        if (ex.value) ex.value = ex.value.replace(REDACT, "[redacted]");
      }
    }

    // Strip sensitive request data captured on the server
    if (event.request) {
      delete event.request.cookies;
      if (event.request.headers) {
        delete event.request.headers["authorization"];
        delete event.request.headers["cookie"];
      }
    }

    return event;
  },
});

Gotcha: Scrub on the server config (sentry.server.config.ts), not just the client — server events carry request bodies and headers where M-Pesa payloads and SENTRY_AUTH_TOKEN-style secrets leak. And never console.log raw callback payloads "for debugging"; if a log integration is enabled, those breadcrumbs ship to Sentry too.

flowchart LR
    A["Error captured<br/>user · request · exception"] --> B["sendDefaultPii false<br/>drops IP · headers · cookies"]
    B --> C["beforeSend hook<br/>redact phones · tokens"]
    C --> D{"return event<br/>or null"}
    D -->|event| E["Server-side scrub<br/>best-effort on ingest"]
    D -->|null| F["Event dropped"]
    E --> G["Stored in Sentry"]

Reference: https://docs.sentry.io/platforms/javascript/guides/nextjs/data-management/sensitive-data


Environment Variables

# DSN (public, safe in frontend)
NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/...
SENTRY_DSN=https://...@sentry.io/...

# Auth token (server-side only — NEVER expose in frontend)
SENTRY_AUTH_TOKEN=sntrys_...

# Organization and project slugs
SENTRY_ORG=my-org
SENTRY_PROJECT=my-project

# Release tracking
NEXT_PUBLIC_APP_VERSION=1.2.3

Automation Workflows

Claude Code Slash Command: Debug Error

.claude/commands/sentry.md:

Investigate the Sentry issue: $ARGUMENTS

1. Use the Sentry MCP tool `get_issue_details` to fetch the full issue with stack trace (issue ID or URL: $ARGUMENTS)
2. Use `analyze_issue_with_seer` to get Seer's root cause analysis
3. Use `search_events` (or `get_event_stacktrace`) to pull the most recent error events
4. Analyze the stack trace and identify the root cause
5. Look at the relevant source files using the Read tool
6. Propose a fix with a code diff
7. Estimate the blast radius (how many users are affected), then optionally `update_issue` to assign/resolve it

Usage: /project:sentry 1234567890 or /project:sentry https://my-org.sentry.io/issues/1234567890/

Hook: Auto-create Sentry Release on Deploy

.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/sentry-release.js"
          }
        ]
      }
    ]
  }
}

scripts/sentry-release.js:

import { execFileSync } from "child_process";

const version = execFileSync("git", ["describe", "--tags", "--abbrev=0"])
  .toString()
  .trim();

if (!version) process.exit(0);

try {
  execFileSync("sentry-cli", ["releases", "new", version], { stdio: "inherit" });
  execFileSync("sentry-cli", ["releases", "set-commits", version, "--auto"], { stdio: "inherit" });
  console.log(`Sentry release created: ${version}`);
} catch (err) {
  console.error("Sentry release failed:", err.message);
}

GitHub Actions: Upload Source Maps

# .github/workflows/sentry-release.yml
name: Sentry Release
on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npm run build
      - name: Create Sentry release
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: |
          npx @sentry/cli releases new ${{ github.ref_name }}
          npx @sentry/cli releases set-commits ${{ github.ref_name }} --auto
          npx @sentry/cli releases files ${{ github.ref_name }} \
            upload-sourcemaps .next --url-prefix "~/_next"
          npx @sentry/cli releases finalize ${{ github.ref_name }}
          npx @sentry/cli releases deploys ${{ github.ref_name }} new \
            --env production

Common Use Cases

Use Case Approach
Debug production error MCP get_issue_details + analyze_issue_with_seer
Triage new issues MCP search_issues + analysis, then update_issue to resolve/assign
Performance investigation MCP get_trace_details + get_span_details (or search_events)
Source map upload sentry-cli releases files upload-sourcemaps
Release tracking sentry-cli releases new + set-commits
User impact assessment MCP get_issue_details (user count field)

Troubleshooting

Issue Fix
OAuth auth fails Clear browser cache and retry /mcp in Claude Code
Token 401 Ensure token has org:read, project:read, issue:read scopes
Source maps not resolving Check --url-prefix matches the deployed JS bundle path
No issues visible Ensure integration has access to the correct organization
Seer analysis empty Issue may be too new; wait a few minutes for analysis to complete

Official docs:

SEO Developer Course & Resource Guide

Technology: seo · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/seo

Insight:

SEO is now two audiences. Search engines crawl → index → rank, gated by robots.txt, sitemap.xml, on-page signals (title/meta/canonical), structured data (JSON-LD), and Core Web Vitals. LLMs (ChatGPT, Claude, Perplexity) increasingly answer for users — and llms.txt is the emerging standard for feeding them a curated, low-token digest of your site. Win both: ship the root files, mark up content with schema.org, pass the Lighthouse SEO audit, and keep LCP ≤ 2.5s. robots.txt is not an index blocker — that's noindex. All file templates below are copy-paste ready.

███████╗███████╗ ██████╗
██╔════╝██╔════╝██╔═══██╗
███████╗█████╗  ██║   ██║
╚════██║██╔══╝  ██║   ██║
███████║███████╗╚██████╔╝
╚══════╝╚══════╝ ╚═════╝

SEO Developer Course & Resource Guide

Focus: A developer's hands-on path to being found — by search engines and by LLMs. Crawlability files (robots.txt, sitemap.xml, llms.txt), on-page signals, structured data, Core Web Vitals, the Lighthouse SEO audit, AI discoverability (GEO), measurement, and a Next.js reference implementation. Grounded in developers.google.com, developer.chrome.com, sitemaps.org, llmstxt.org, and schema.org; reviewed 2026-08-23.

How this course works

Six parts, each ending with a ✅ capability checkpoint and a 🛠 exercise. The interactive learn module above this page — a live SERP + social card + structured-data previewer — is your illustration for Parts 2–3; edit a title/description there and watch the search snippet and length warnings update. Every file you need is in §Templates.

Table of Contents

  1. How discovery works — crawl → index → rank → cite
  2. Crawlability files — robots.txt · sitemap.xml · llms.txt
  3. On-page SEO — titles, meta, canonical, Open Graph
  4. Structured data — JSON-LD + rich results
  5. Technical SEO & performance — Core Web Vitals, Lighthouse SEO audit
  6. AI discoverability (GEO) — ranking in LLMs
  7. Measurement · Next.js implementation · Templates · codeAmani notes

Official Documentation

Resource URL
Google Search Central https://developers.google.com/search/docs
Lighthouse SEO audits https://developer.chrome.com/docs/lighthouse/seo
robots.txt (Google) https://developers.google.com/search/docs/crawling-indexing/robots/intro
Sitemap protocol https://www.sitemaps.org/protocol.html
Structured data (Search Gallery) https://developers.google.com/search/docs/appearance/structured-data/search-gallery
schema.org https://schema.org/
llms.txt proposal https://llmstxt.org/
Core Web Vitals https://web.dev/articles/vitals
Rich Results Test https://search.google.com/test/rich-results

1. How discovery works: crawl → index → rank → cite

flowchart LR
  C["Crawl<br/>robots.txt"] --> I["Index<br/>sitemap + clean HTML"]
  I --> R["Rank<br/>signals + Core Web Vitals"]
  R --> S["SERP result"]
  C --> L["llms.txt"]
  L --> RT["LLM retrieves"]
  RT --> CT["Cited in AI answer"]
SEARCH ENGINES                                   LLMs (new)
 crawl ──▶ index ──▶ rank ──▶ SERP               retrieve ──▶ synthesize ──▶ cite
   │         │         │                              │
 robots.txt  sitemap   on-page signals +          llms.txt + clean content +
 (may/can't) (what     structured data +          structured data + being
             exists)   Core Web Vitals            quotable & authoritative

💡 The mindset shift: you're no longer optimising only for ten blue links. You're optimising to be the answer — in a SERP rich result and in a chatbot's cited response.


2. Crawlability: the root files

Three files at your domain root, each for a different reader.

robots.txt — where crawlers may go

Lives at /robots.txt. Controls crawl traffic; it is not a way to hide a page from the index — a blocked-but-linked page can still appear (use noindex to truly exclude).

# /robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/

# Point crawlers (and many AI bots) at your sitemap
Sitemap: https://example.com/sitemap.xml

sitemap.xml — what exists

Lives at /sitemap.xml. Lists canonical URLs so crawlers discover everything. Namespace http://www.sitemaps.org/schemas/sitemap/0.9; <loc> is required, the rest optional. Limits: 50,000 URLs / 50 MB per file — beyond that, split and use a sitemap index.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/</loc>
    <lastmod>2026-06-07</lastmod>
    <changefreq>weekly</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://example.com/pricing</loc>
    <lastmod>2026-06-01</lastmod>
  </url>
</urlset>

llms.txt — a digest for LLMs

The emerging standard (llmstxt.org) for feeding LLMs a curated, low-token map of your site at /llms.txt. Markdown, in this exact order: an H1 name (only required part), a blockquote summary, optional body, then H2 sections of [link](url): description lists, with a skippable ## Optional section.

# codeAmani Labs

> AI-augmented products for the Kenyan and East African market — M-Pesa payments,
> Next.js apps, and developer tooling.

codeAmani builds mobile-first SaaS with M-Pesa as the default payment rail.

## Docs
- [Tech stack](https://example.com/stack.md): Our stack and conventions
- [M-Pesa integration](https://example.com/mpesa.md): Daraja STK Push lifecycle

## Optional
- [Blog](https://example.com/blog.md): Background articles

Also serve markdown versions of pages (append .md to a URL) and optionally an llms-full.txt (everything concatenated) so an LLM can ingest clean content without parsing HTML.

✅ Checkpoint: You can write a correct robots.txt, a valid sitemap, and an llms.txt — and you know robots.txt ≠ noindex. 🛠 Exercise: Write all three for a 3-page site, then validate the sitemap in Google Search Console.


3. On-page SEO: the signals on every page

<head>
  <!-- Title: the SERP headline + browser tab. Front-load the keyword; ~50–60 chars. -->
  <title>M-Pesa Integration Guide | codeAmani Labs</title>

  <!-- Meta description: the SERP snippet. ~150–160 chars, compelling, unique per page. -->
  <meta name="description" content="Integrate M-Pesa STK Push with Daraja in Next.js — auth, callbacks, and idempotency, with copy-paste code.">

  <!-- Canonical: the one true URL for this content (kills duplicate-content dilution). -->
  <link rel="canonical" href="https://example.com/mpesa">

  <!-- Crawl directives (per-page; THIS is how you exclude from the index). -->
  <meta name="robots" content="index, follow">

  <!-- Open Graph — the social/link-preview card (LinkedIn, WhatsApp, Slack). -->
  <meta property="og:title" content="M-Pesa Integration Guide">
  <meta property="og:description" content="Daraja STK Push in Next.js, step by step.">
  <meta property="og:image" content="https://example.com/og/mpesa.png">
  <meta property="og:type" content="article">
  <meta property="og:url" content="https://example.com/mpesa">

  <!-- Twitter/X card -->
  <meta name="twitter:card" content="summary_large_image">

  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

Also on every page: one <h1>, a logical heading outline (h2/h3), descriptive alt on images, descriptive link text (not "click here"), semantic HTML (<nav>, <main>, <article>), and hreflang if you serve multiple languages/regions.

✅ Checkpoint: Every page has a unique title + description, a canonical, OG tags, one h1, and alt text.


4. Structured data (JSON-LD)

Machine-readable facts about the page that earn rich results (stars, FAQs, breadcrumbs) in search and feed LLMs clean entities. Google recommends JSON-LD in a <script>. Validate with the Rich Results Test.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "M-Pesa Integration Guide",
  "author": { "@type": "Organization", "name": "codeAmani Labs" },
  "datePublished": "2026-06-07",
  "image": "https://example.com/og/mpesa.png"
}
</script>

Common high-value types: Article, BreadcrumbList, FAQPage, Product (with Offer + AggregateRating), Organization, LocalBusiness, HowTo, WebSite (with SearchAction). Use schema.org for the full vocabulary; type your objects with schema-dts in TypeScript.

// FAQPage — earns an expandable FAQ rich result
{
  "@context": "https://schema.org", "@type": "FAQPage",
  "mainEntity": [{ "@type": "Question", "name": "Does it support M-Pesa?",
    "acceptedAnswer": { "@type": "Answer", "text": "Yes — Daraja STK Push is first-class." } }]
}

✅ Checkpoint: Your key pages carry valid JSON-LD that passes the Rich Results Test.


5. Technical SEO & performance

Core Web Vitals (a ranking signal)

Page experience counts. Targets at the 75th percentile (see the chrome-devtools guide):

Metric Good Measures
LCP ≤ 2.5 s Loading
INP ≤ 200 ms Interactivity
CLS ≤ 0.1 Visual stability

The Lighthouse SEO audit

Run it (DevTools → Lighthouse, or lhci). The SEO category scans automatable signals — each weighted equally (except the manual structured-data check):

# CI gate — fail the PR if SEO regresses (see github guide for the workflow)
npx lighthouse https://example.com --only-categories=seo,performance --output json

✅ Checkpoint: Lighthouse SEO ≥ 0.9 and LCP ≤ 2.5 s on mobile.


6. AI discoverability (GEO — ranking in LLMs)

"Generative Engine Optimization": being the source an LLM retrieves and cites. Search and LLM optimisation overlap, but LLMs reward different things:

# robots.txt — example: allow search + select AI crawlers, block one
User-agent: GPTBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap.xml

✅ Checkpoint: You serve llms.txt + markdown content and have made an explicit allow/deny decision for AI crawlers.


7. Measurement

Tool What it tells you
Google Search Console Impressions, clicks, average position, indexing status, Core Web Vitals (field), sitemap health
Lighthouse / PageSpeed Insights Lab SEO + performance scores per page
web-vitals library Real-user (field) CWV from your own analytics
Rich Results Test / Schema validator Whether your JSON-LD earns rich results
import { onLCP, onINP, onCLS } from "web-vitals";
const send = (m: unknown) => navigator.sendBeacon("/vitals", JSON.stringify(m));
onLCP(send); onINP(send); onCLS(send);   // report real-user vitals

Metrics that matter (in order): indexed pages → impressions → average position → CTR (driven by title/description) → conversions. Vanity keyword rankings are downstream of these.


8. Next.js reference implementation

Next.js (App Router, the codeAmani default — verified on v15/v16; the Metadata API is identical across both, current stable 16.3.2) generates the files and tags natively.

// app/robots.ts → serves /robots.txt
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/admin/", "/api/"] }],
    sitemap: "https://example.com/sitemap.xml",
  };
}
// app/sitemap.ts → serves /sitemap.xml
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: "https://example.com/", lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
    { url: "https://example.com/pricing", lastModified: new Date() },
  ];
}
// app/mpesa/page.tsx → per-page metadata (title, description, canonical, OG)
import type { Metadata } from "next";
export const metadata: Metadata = {
  title: "M-Pesa Integration Guide | codeAmani Labs",
  description: "Integrate M-Pesa STK Push with Daraja in Next.js — auth, callbacks, idempotency.",
  alternates: { canonical: "https://example.com/mpesa" },
  openGraph: { title: "M-Pesa Integration Guide", images: ["/og/mpesa.png"], type: "article" },
  twitter: { card: "summary_large_image" },
};
// JSON-LD in a Server Component — render the script inline
export default function Page() {
  const ld = { "@context": "https://schema.org", "@type": "Article", headline: "M-Pesa Integration Guide" };
  return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />;
}

For llms.txt, add a route (app/llms.txt/route.ts) or a static file in public/. next-sitemap automates large/multi-sitemap setups.


Copy-paste file templates

Everything you need, ready to drop into any codeAmani project (replace example.com):

Troubleshooting

Issue Fix
Page not indexed Check Search Console coverage; ensure no noindex, page is in the sitemap, and it's crawlable
"Blocked by robots.txt" but should be public Remove the Disallow; remember robots.txt ≠ noindex
Blocked page still showing in results Add <meta name="robots" content="noindex"> (robots.txt alone won't remove it)
Rich result not appearing Validate JSON-LD in the Rich Results Test; fix required-property errors
Duplicate content Set a rel=canonical to the preferred URL
Low CTR despite ranking Rewrite the title + meta description to be compelling
Poor mobile ranking Fix Core Web Vitals (LCP ≤ 2.5s) and the viewport tag
LLMs don't cite you Ship llms.txt + .md content; make claims quotable; don't block AI bots

codeAmani notes

Official docs:

SLSA Provenance & Supply-Chain Integrity

Technology: supply-chain · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/supply-chain

Insight:

SLSA levels are Build-track assurance levels, not a tool you install. Default any artifact that ships (npm package, release tarball, container) to Build L3 via the isolated builder — npm publish --provenance alone is only L2. Anything that does not ship a downloadable artifact (this KB, Vercel apps) needs no provenance: pin your Actions and document the build instead.

███████╗██╗     ███████╗ █████╗
██╔════╝██║     ██╔════╝██╔══██╗
███████╗██║     ███████╗███████║
╚════██║██║     ╚════██║██╔══██║
███████║███████╗███████║██║  ██║
╚══════╝╚══════╝╚══════╝╚═╝  ╚═╝

SLSA (Supply-chain Levels for Software Artifacts) is an OpenSSF framework for build integrity. It answers one question for whoever installs your artifact: "Here is the verifiable, unforgeable record of how and where this was built." This guide is policy for codeAmani: decide a provenance target at plan time, not at release.


What SLSA is (and isn't)

SLSA is not a package, an action, or a vendor. It is a maturity framework. The famous numbers (L1/L2/L3) are assurance levels on the Build track — each adds a stronger guarantee about how trustworthy an artifact's provenance is.

Level Guarantee Roughly achieved by
Build L1 Provenance exists — an automated build emits a record of how the artifact was made. Falsifiable. A build script + recorded metadata
Build L2 Provenance is signed & authenticated, built on a hosted platform. Binds artifact → source repo + builder. npm publish --provenance from GitHub Actions
Build L3 + Build isolation / non-falsifiable — signing happens in a trusted control plane the build steps cannot reach. slsa-github-generator isolated builder

SLSA v1.2 (the current approved spec, superseding v1.1) formally introduces a Source track (commit/review integrity) alongside the Build track, and updates the threat model to cover it. The L1–L3 most people mean are still Build-track; this guide targets the Build track.

The L2 → L3 jump is a trust-model choice

That is why npm publish --provenance is only L2 — build and signing share one tenant-controlled job. L3 requires the isolated builder below.


Decision rubric — does this project ship an artifact?

This is the only question that matters. SLSA protects distributed artifacts. If nothing is downloaded, there is nothing to attest.

Project archetype Ships a downloadable artifact? Target Recipe
Knowledge base / docs (e.g. the tech-stack repo) No n/a — pin Actions, document build —
Next.js app on Netlify/Vercel (dashboard, mail, kipaji-web) No — a deploy, not an artifact SLSA-aware only hardened CI, no provenance
Published npm package / CLI / MCP server Yes (registry) Build L3 npm isolated builder ↓
GitHub Release tarball Yes (release asset) Build L3 generic generator ↓
Container image Yes (registry) Build L3 container generator

Netlify/Vercel deployments do not emit SLSA3 provenance. To put provenance on a deployed app you would have to build the deployable artifact in GitHub Actions with the generic generator and deploy that attested output — heavy, and it fights Vercel's build-on-push model. Not worth it unless an artifact genuinely ships.


Recipe A — npm package → Build L3

Use the Node.js isolated builder. It builds in a control plane your scripts can't reach, generates non-falsifiable provenance, and the companion publish action pushes the package + provenance to npm.

Prerequisites on the package:

.github/workflows/release-npm-slsa3.yml:

name: release-npm-slsa3
on:
  push:
    tags: ["v*"]

permissions: read-all   # tighten per-job below

jobs:
  build:
    permissions:
      id-token: write   # OIDC token for Sigstore signing
      contents: read    # checkout
      actions: read     # read workflow run metadata
    if: startsWith(github.ref, 'refs/tags/')
    uses: slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml@v2.1.0
    with:
      # In a monorepo, point at the package dir, e.g. packages/mcp-server
      run-scripts: "ci, test, build"   # run INSIDE the isolated builder before packing

  publish:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - name: Set up npm registry auth
        uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: "https://registry.npmjs.org"
      - name: publish (package + provenance)
        uses: slsa-framework/slsa-github-generator/actions/nodejs/publish@v2.1.0
        with:
          access: public
          node-auth-token: ${{ secrets.NPM_TOKEN }}
          package-name: ${{ needs.build.outputs.package-name }}
          package-download-name: ${{ needs.build.outputs.package-download-name }}
          package-download-sha256: ${{ needs.build.outputs.package-download-sha256 }}
          provenance-name: ${{ needs.build.outputs.provenance-name }}
          provenance-download-name: ${{ needs.build.outputs.provenance-download-name }}
          provenance-download-sha256: ${{ needs.build.outputs.provenance-download-sha256 }}

The reusable workflow must be pinned to a @vX.Y.Z tag (not a branch or short SHA) — the generator refuses to run otherwise. Pin your other actions (setup-node, checkout) to full commit SHAs per house security policy.


Recipe B — GitHub Release tarball → Build L3

When you ship a downloadable asset (not a registry package), build it yourself, then hand the subjects (sha256 of each artifact, base64-encoded) to the generic generator, which signs and attaches .intoto.jsonl provenance to the Release.

.github/workflows/release-tarball-slsa3.yml:

name: release-tarball-slsa3
on:
  push:
    tags: ["v*"]

permissions: read-all

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      hashes: ${{ steps.hash.outputs.hashes }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci && npm run build
      - name: pack artifact
        run: tar -czf dist.tar.gz dist/
      - name: compute subjects
        id: hash
        run: echo "hashes=$(sha256sum dist.tar.gz | base64 -w0)" >> "$GITHUB_OUTPUT"

  provenance:
    needs: [build]
    permissions:
      actions: read     # detect the Actions environment
      id-token: write   # Sigstore signing
      contents: write   # upload provenance to the Release
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
    with:
      base64-subjects: "${{ needs.build.outputs.hashes }}"
      upload-assets: true   # attach <artifact>.intoto.jsonl to the Release

Recipe C — GitHub artifact attestations (lighter, ≈ L2)

When the full isolated builder is more ceremony than you want, GitHub's own actions/attest-build-provenance (currently v4 — as of v4 it is a thin wrapper over actions/attest) emits a signed SLSA build-provenance attestation for any artifact in one step, signed via Sigstore (the public-good instance for public repos, GitHub's private instance for private/internal repos) and stored in GitHub's attestations API.

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write     # Sigstore/OIDC signing
      contents: read
      attestations: write # write to the attestations API
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build && tar -czf dist.tar.gz dist/
      - uses: actions/attest-build-provenance@v4
        with:
          subject-path: dist.tar.gz

Verify on the consumer side with the GitHub CLI:

gh attestation verify dist.tar.gz --repo codeAmani-Solutions/<repo>

This is L2-class, not L3. Provenance is generated in the same tenant-controlled job that runs the build — there is no build isolation, so it carries the same L2 trust model as npm publish --provenance. Reach for it when you want signed, verifiable provenance with minimal wiring; use the isolated builder (Recipe A/B) when the artifact genuinely warrants L3.


Verification (the half people skip)

Provenance that nobody verifies buys nothing. Two consumer-side paths:

npm packages — simplest is the npm CLI after install:

npm audit signatures        # reports registry signature + provenance attestation status

Release tarballs / generic artifacts — use slsa-verifier:

# install (Go) — or grab a release binary
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@v2.7.1

slsa-verifier verify-artifact dist.tar.gz \
  --provenance-path dist.tar.gz.intoto.jsonl \
  --source-uri github.com/codeAmani-Solutions/<repo> \
  --source-tag v1.2.3

verify-artifact fails unless the artifact's digest, the source repo, and the builder identity all match — this is what makes a forged or swapped artifact detectable.

CI gate: for any dependency that publishes provenance, add a verify step in CI so an unverifiable build fails rather than silently proceeding.


What SLSA3 does NOT do (be honest about the boundary)

SLSA attests build integrity, not source benevolence. As of 2026 (see the OpenSSF "Mini Shai-Hulud" analysis), L3 provenance will faithfully sign an artifact built from malicious source or a compromised dependency — it proves the how/where, not that the code is safe. Provenance complements, does not replace:

It defeats substitution attacks (dependency confusion, tampered artifacts, impostor publishers) — which is real, high-value coverage — not insider attacks.


codeAmani policy (planning & build integration)

  1. Plan-time decision. Every new project's plan records a Provenance target using the rubric above. Default for anything that ships an artifact: Build L3.
  2. Template-scaffolded. New shippable projects inherit a commented release workflow from codeAmani-labs-projects/_TEMPLATE-PROJECT/.github/workflows/release-slsa3.yml — enable it on first publish; zero retrofit.
  3. Always-loaded policy. The summary rubric lives in the root CLAUDE.md (## Supply-Chain & Provenance (SLSA)), so every Claude Code session inherits it.
  4. Freshness-tracked. The docs: URLs above are watched by the tech-stack checker; when the builder ships a new major (v2 → v3) this guide is flagged for review.

References

Official docs:

Stripe Integration Guide

Technology: stripe · Category: payments · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/stripe

Insight:

Stripe is codeAmani's default payment rail — the US-first customer base pays by card and wallet, and Stripe Checkout is the shortest path to production. M-Pesa (Daraja) is a separate rail used only for Kenya-targeted projects. Pin your apiVersion explicitly, verify every webhook signature, and pass an idempotency key on every state-changing create call.

███████╗████████╗██████╗ ██╗██████╗ ███████╗
██╔════╝╚══██╔══╝██╔══██╗██║██╔══██╗██╔════╝
███████╗   ██║   ██████╔╝██║██████╔╝█████╗
╚════██║   ██║   ██╔══██╗██║██╔═══╝ ██╔══╝
███████║   ██║   ██║  ██║██║██║     ███████╗
╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝╚═╝     ╚══════╝

Stripe Integration Guide

Focus: Cards, wallets, subscriptions, and billing — codeAmani's default payment rail for its US-first customer base.

Overview

Stripe handles card and wallet payments globally: one-time charges, subscription billing, invoicing, and hosted Stripe Checkout. Per CLAUDE.md, Stripe is the default payment rail for every codeAmani project. M-Pesa via the Daraja API is a separate rail used only when a project's primary users are in Kenya — see MPESA_PATTERNS.md. The two are alternatives selected per project, not a primary/secondary pair.

Here is the big picture — the payment lifecycle is a clean, predictable loop:

flowchart LR
  A["Customer pays<br/>card or checkout"] --> B["Your server<br/>creates PaymentIntent"]
  B --> C["Payment Element<br/>confirms payment"]
  C --> D["Stripe sends<br/>signed webhook"]
  D --> E["Your server<br/>verifies signature"]
  E --> F["Update DB<br/>and fulfill"]

Official Documentation

Resource URL
Stripe Docs https://docs.stripe.com
API Reference https://docs.stripe.com/api
Developer Changelog https://docs.stripe.com/changelog
Stripe CLI https://docs.stripe.com/stripe-cli
Webhooks https://docs.stripe.com/webhooks
MCP server https://docs.stripe.com/mcp
Agent skills https://docs.stripe.com/skills
Testing https://docs.stripe.com/testing
Node.js SDK https://github.com/stripe/stripe-node

API versioning — pin it explicitly

Stripe uses a flora-named release model: YYYY-MM-DD.codename. A new codename means breaking changes; monthly releases inside a codename are additive-only and safe to adopt.

Release First version Latest monthly (2026-08-23)
Acacia 2024-09-30.acacia —
Basil 2025-03-31.basil 2025-08-27.basil
Clover 2025-09-30.clover 2026-02-25.clover
Dahlia (current) 2026-03-25.dahlia 2026-07-29.dahlia

stripe-node v22.5.0 ships ApiVersion = '2026-07-29.dahlia'. Always pin the version in code rather than relying on your account default — otherwise a dashboard upgrade silently changes the response shapes your code parses.

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-07-29.dahlia",
});

A version string must include the codename suffix. "2025-04-30" on its own is not a valid Stripe API version.

Breaking changes that affect existing code

Change Release What to do
latest_invoice.payment_intent replaced by latest_invoice.confirmation_secret Basil+ Expand latest_invoice.confirmation_secret when creating incomplete subscriptions
Flexible billing mode is the default for new subscriptions Clover 2025-09-30 Set billing_mode: { type: "flexible" } explicitly
Checkout postpones subscription creation until after payment Basil 2025-03-31 Don't assume the subscription exists before checkout.session.completed
Legacy Stripe.js methods removed Dahlia 2026-03-25 Replace handleCardPayment, confirmPaymentIntent, handleFpxPayment, handleCardSetup, confirmSetupIntent, createSource, retrieveSource
initCheckout → initCheckoutElements Dahlia 2026-03-25 Rename the call
Checkout collected_information.tax_ids → tax_id Dahlia 2026-07-29 Rename the field read
total_count expansion removed on list responses Basil 2025-03-31 Count separately

stripe.confirmPayment({ elements, confirmParams }) was not removed — it is still the current method. Only the older intent-specific helpers listed above were.


Environments — Sandbox vs Live

Stripe has two kinds of environment: sandboxes (isolated test environments) and live mode (real money). Payments made in a sandbox are never processed by card networks.

flowchart TB
  subgraph SB["Sandbox · no real money"]
    S1["sk_test_… / pk_test_…"]
    S2["Test cards 4242…"]
    S3["stripe listen → whsec_… (test)"]
  end
  subgraph LV["Live · real money"]
    L1["sk_live_… / pk_live_…"]
    L2["Real cards"]
    L3["Dashboard endpoint → whsec_… (live)"]
  end
  SB -.->|"promote after testing"| LV

Sandboxes

A sandbox is an isolated environment inside your account. Each sandbox has its own data and its own API keys, so teammates can test without colliding.

# Provision a sandbox from the CLI (no account registration required)
stripe sandbox create --help

Access sandboxes from Dashboard → account picker → Sandboxes. You can invite an external collaborator into a single sandbox without granting any live-mode access.

Sandbox limitations that matter to us:

Limitation Impact
Can't test IC+ pricing Cost models must use published rates
Can't connect a platform sandbox to connected-account sandboxes Vertiq's Connect flows can't be fully end-to-end tested across sandboxes — plan a live-mode pilot with a real test seller

Live mode

Live mode requires a fully activated account (business details, bank account, tax info). Before flipping any project to live:

  1. Swap sk_test_ / pk_test_ for sk_live_ / pk_live_.
  2. Register the production webhook endpoint in the Dashboard and use its whsec_... — the one from stripe listen is sandbox-only.
  3. Re-verify apiVersion is pinned in code.
  4. Run one real low-value transaction and refund it.

Keys — test vs live

Stripe keys are environment-scoped and namespaced. A test key can never touch live objects, and vice versa — this is the usual cause of No such payment_intent.

Key Prefix Where it lives Notes
Secret key sk_test_ / sk_live_ Server only Full account access. Never in a client bundle
Publishable key pk_test_ / pk_live_ Browser (safe) Only identifies your account to Stripe.js
Restricted key rk_test_ / rk_live_ Server / agents Scoped permissions — use for MCP + agents
Webhook signing secret whsec_ Server only Per-endpoint; stripe listen prints a sandbox-only one

Rules we follow:


Secrets management with Hazina

codeAmani stores Stripe credentials in Hazina, the local encrypted vault (packages/hazina), not in loose .env files. The agent workflow is zero-exposure: Claude handles references and env-var names only, never values.

Catalog the Stripe secrets once (the user runs these — hidden prompt, values never enter chat):

hazina add stripe/secret_key       --type apikey
hazina add stripe/publishable_key  --type apikey
hazina add stripe/webhook_secret   --type apikey
hazina add stripe/restricted_key   --type apikey   # for MCP / agents

Bind a project's env vars to those references, then inject:

hazina bind <project> --path <projectDir> \
  --add STRIPE_SECRET_KEY=stripe/secret_key \
  --add NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=stripe/publishable_key \
  --add STRIPE_WEBHOOK_SECRET=stripe/webhook_secret

hazina inject <project>          # writes gitignored .env.local; reports key NAMES only
hazina push <project> --provider vercel --target production

Rules:


MCP Server Setup

Stripe's official MCP server is now a remote, OAuth-authenticated server at https://mcp.stripe.com. This replaces the older local @stripe/mcp stdio package as the documented default.

claude mcp add --transport http stripe https://mcp.stripe.com/

Then authenticate — this opens a Stripe OAuth consent screen:

claude /mcp

OAuth is preferred over a secret key because it grants granular, user-scoped, revocable access. Review authorized sessions under Dashboard → user settings → OAuth sessions.

For headless/agent contexts that cannot do OAuth, pass a restricted API key (rk_..., never a full sk_...) as a bearer token:

// .mcp.json — prefer OAuth; use this only for non-interactive agents
{
  "mcpServers": {
    "stripe": {
      "url": "https://mcp.stripe.com",
      "headers": { "Authorization": "Bearer ${STRIPE_RESTRICTED_KEY}" }
    }
  }
}

Key MCP Tools

The server exposes generic API tools rather than one tool per endpoint, which keeps the context window small:

Tool Description
stripe_api_search Find Stripe API methods by keyword
stripe_api_details Get parameter detail for a specific method
stripe_api_read Call any Stripe GET method
stripe_api_write Call any POST / PATCH / PUT / DELETE method
search_stripe_documentation Search docs and support articles
stripe_implementation_planner Guided planning for a Stripe integration
get_stripe_account_info Retrieve account details
create_refund Issue a refund
stripe_report Search, retrieve, and create reports

Prompt-injection caution: stripe_api_write can move money. Keep human confirmation enabled for write tools, and be careful combining the Stripe MCP with untrusted content sources.


Stripe CLI Setup

The CLI is distributed on npm as @stripe/cli (v1.50.4). There is no @stripe/stripe-cli package.

npm install -g @stripe/cli

stripe login

# Forward webhooks to your local dev server (prints a test-mode whsec_...)
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed

Agent skills

Stripe ships first-party skills that keep an agent's Stripe knowledge current (requires CLI v1.43.3+):

stripe agent setup      # installs stripe-docs, stripe-best-practices, upgrade-stripe
stripe docs /payments   # read any docs page as Markdown in the terminal
stripe docs search "payment intents"
stripe docs api GET /v1/products

Prefer stripe docs over scraping docs.stripe.com — it returns agent-ready Markdown. Note stripe docs requires a valid login; re-run stripe login if you see "The API key provided has expired."


SDK Setup

npm install stripe                                  # server — v22.5.0
npm install @stripe/stripe-js @stripe/react-stripe-js  # client — v9.14.0 / v6.8.2
// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-07-29.dahlia",
});

Core Patterns

Payment Intent (one-time charge)

// app/api/payments/create-intent/route.ts
import { NextRequest } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: NextRequest) {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const { amount, currency = "usd", orderId } = await req.json();

  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount,          // smallest currency unit — 2000 = $20.00
      currency,
      metadata: { userId, orderId },
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey: `pi_${orderId}` }, // RequestOptions — last arg
  );

  return Response.json({ clientSecret: paymentIntent.client_secret });
}

Stripe Checkout Session

// app/api/checkout/route.ts
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [
    {
      price_data: {
        currency: "usd",
        unit_amount: 2000, // $20.00
        product_data: { name: "codeAmani Pro Plan" },
      },
      quantity: 1,
    },
  ],
  success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.APP_URL}/pricing`,
  metadata: { userId },
});

return Response.json({ url: session.url });

Subscription

Expand latest_invoice.confirmation_secret — the old latest_invoice.payment_intent expansion no longer exists, and set billing_mode explicitly so a future default change cannot move under you.

const customer = await stripe.customers.create({
  email: userEmail,
  metadata: { userId },
});

const subscription = await stripe.subscriptions.create({
  customer: customer.id,
  items: [{ price: process.env.STRIPE_PRICE_ID! }],
  payment_behavior: "default_incomplete",
  payment_settings: { save_default_payment_method: "on_subscription" },
  billing_mode: { type: "flexible" },
  expand: ["latest_invoice.confirmation_secret"],
});

// Hand this to the browser to confirm with the Payment Element
const clientSecret =
  (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret;

Webhook Handler

This is the trustworthy core of fulfillment — verify the signature first, then act:

sequenceDiagram
  participant C as "Customer"
  participant S as "Your server"
  participant ST as "Stripe"
  participant DB as "Database"
  C->>S: Request PaymentIntent
  S->>ST: paymentIntents.create
  ST-->>S: client_secret
  S-->>C: client_secret
  C->>ST: confirmPayment via Payment Element
  ST->>S: Webhook payment_intent.succeeded
  S->>ST: constructEvent verify signature
  S->>DB: Mark payment succeeded
  S-->>ST: Respond 2xx
// app/api/webhooks/stripe/route.ts
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";

export const runtime = "nodejs"; // signature verification needs Node crypto

export async function POST(req: NextRequest) {
  const body = await req.text();                       // RAW body — never req.json()
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("Webhook signature verification failed", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      // Provision access for session.metadata?.userId
      break;
    }
    case "payment_intent.succeeded": {
      const pi = event.data.object as Stripe.PaymentIntent;
      // Mark payment succeeded
      break;
    }
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      // Downgrade user access
      break;
    }
  }

  return new Response("OK");
}

Idempotency & reconciliation

Network blips, double-clicks, and serverless retries happen — and each one risks charging a customer twice. The fix: pass an idempotency key on every state-changing create call, and dedupe webhook events on their id. Stripe stores the result of the first request under that key, so any retry with the same key returns the original PaymentIntent instead of creating a new charge. Generate one stable key per logical operation (e.g. tied to a cart or order), not per HTTP attempt.

The key goes in the options object — the last argument to any method:

const idempotencyKey = `pi_${orderId}`; // stable across retries

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount,
    currency,
    metadata: { userId, orderId },
    automatic_payment_methods: { enabled: true },
  },
  { idempotencyKey }, // <-- RequestOptions, last arg
);

Fulfillment must be idempotent too. Stripe can deliver the same event more than once, so record each event.id and skip anything already processed before you fulfill:

// inside the webhook handler, after constructEvent succeeds
const alreadyProcessed = await db.webhookEvents.exists(event.id);
if (alreadyProcessed) return new Response("OK"); // dedupe — no-op replay

await db.webhookEvents.insert({ id: event.id, type: event.type });
// ...now safe to fulfill exactly once
flowchart TD
  A["Create PaymentIntent<br/>with idempotencyKey"] --> B{"Key seen<br/>before by Stripe"}
  B -->|"yes"| C["Return original<br/>PaymentIntent · no new charge"]
  B -->|"no"| D["Create new<br/>PaymentIntent"]
  D --> E["Webhook arrives"]
  C --> E
  E --> F{"event.id in<br/>processed log"}
  F -->|"yes"| G["Skip · already fulfilled"]
  F -->|"no"| H["Record id<br/>then fulfill once"]

Source: Idempotent requests. Keys are stored by Stripe and expire after 24 hours — they protect against retries, not against a deliberate re-charge a day later.


Client-Side (Payment Element)

npm install @stripe/stripe-js @stripe/react-stripe-js
// components/CheckoutForm.tsx
"use client";
import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";

export function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    // confirmPayment is current — the legacy handleCardPayment /
    // confirmPaymentIntent helpers were removed in Dahlia (2026-03-25).
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${window.location.origin}/payment-success` },
    });

    if (error) {
      // Surface error.message to the user — do not treat this as authoritative
      // failure; the webhook is the source of truth.
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit" disabled={!stripe}>Pay</button>
    </form>
  );
}
// app/checkout/page.tsx
import { loadStripe } from "@stripe/stripe-js";
import { Elements } from "@stripe/react-stripe-js";

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

export default function CheckoutPage({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  );
}

Test Cards

Card Number Behavior
4242 4242 4242 4242 Successful payment
4000 0000 0000 9995 Declined (insufficient funds)
4000 0025 0000 3155 3D Secure authentication required
4000 0000 0000 0002 Generic decline

Use any future expiry date and any 3-digit CVC. Full matrix: https://docs.stripe.com/testing


Environment Variables

# Required
STRIPE_SECRET_KEY=sk_live_...          # Never expose — server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Optional
STRIPE_PRICE_ID=price_...              # Default subscription price ID
STRIPE_RESTRICTED_KEY=rk_...           # Scoped key for agent/MCP contexts

For local dev use sk_test_... / pk_test_.... The whsec_... printed by stripe listen is test-mode only — production uses the signing secret from the Dashboard endpoint.


Common Use Cases

Use Case Approach
One-time payment PaymentIntent + Payment Element
Hosted checkout checkout.sessions.create → redirect
Subscription billing checkout.sessions.create in subscription mode, or subscriptions.create + billing portal
Card saves setupIntents.create + Payment Methods API
Invoicing invoices.create + invoices.sendInvoice
Refunds refunds.create (also exposed as an MCP tool)

Setting up payments

Single-merchant projects (the default)

Most codeAmani products collect payments for themselves. That is the standard setup covered above:

  1. Activate the Stripe account; capture keys into Hazina.
  2. lib/stripe.ts with a pinned apiVersion.
  3. A server route that creates a Checkout Session or PaymentIntent.
  4. A signature-verifying webhook route that is the only thing that fulfills.
  5. stripe listen locally; a Dashboard endpoint in production.

Marketplaces — Vertiq.market

Vertiq Market is a multi-party marketplace: independent sellers list digital goods, buyers pay, and Vertiq takes a commission. The explicit requirement is that sellers receive the payments and are responsible for them — Vertiq is not the party handling the money.

That maps to exactly one Stripe design: Connect with direct charges onto seller-owned accounts.

flowchart LR
  B["Buyer"] -->|"pays"| SA["Seller's Stripe account<br/>(merchant of record)"]
  SA -->|"application_fee_amount"| V["Vertiq platform<br/>(commission only)"]
  SA -->|"payout"| SB["Seller's bank"]
  SA -.->|"refunds · chargebacks<br/>debit THIS balance"| SA

Why direct charges, not destination charges: with destination charges the money lands in the platform's balance and refunds/chargebacks debit the platform. That is precisely the liability Vertiq is trying not to hold. With direct charges the funds never touch Vertiq's balance — only the commission does.

Property Direct charges (Vertiq) Destination charges (rejected)
Funds land in Seller's balance Platform balance
Merchant of record Seller Platform
Refund debits Seller's balance Platform balance
Chargeback debits Seller's balance Platform balance
Statement descriptor Seller's Platform's
Platform revenue application_fee_amount Amount retained

1. Create seller accounts

New platforms should use the Accounts v2 API. The three settings that assign responsibility away from Vertiq:

Setting Value Effect
defaults.responsibilities.losses_collector stripe Stripe — not Vertiq — is liable for the seller's negative balances
defaults.responsibilities.fees_collector stripe Stripe bills processing fees to the seller directly
dashboard full Seller gets the full Stripe Dashboard and self-serves refunds/disputes
// Accounts v2 — recommended for new platforms
const account = await stripe.v2.core.accounts.create({
  contact_email: sellerEmail,
  dashboard: "full",
  defaults: {
    responsibilities: {
      losses_collector: "stripe",   // Stripe bears seller losses, not Vertiq
      fees_collector: "stripe",     // seller pays Stripe fees directly
    },
  },
  configuration: { merchant: {} },  // enables accepting payments
  include: ["configuration.merchant"],
});

The equivalent on the v1 Accounts API with controller properties:

const account = await stripe.accounts.create({
  email: sellerEmail,
  controller: {
    losses: { payments: "stripe" },      // seller/Stripe bear losses
    fees: { payer: "account" },          // seller pays Stripe fees
    stripe_dashboard: { type: "full" },  // full Dashboard access
    requirement_collection: "stripe",    // Stripe runs KYC
  },
});

defaults.responsibilities cannot be changed after creation, and an account's country is fixed. Get this right on the first seller.

2. Onboard the seller

Stripe runs KYC and identity verification — Vertiq never collects government IDs:

const link = await stripe.accountLinks.create({
  account: account.id,
  type: "account_onboarding",
  refresh_url: `${process.env.APP_URL}/sellers/onboarding/refresh`,
  return_url: `${process.env.APP_URL}/sellers/onboarding/complete`,
});
// redirect the seller to link.url

Before letting a seller list anything, confirm the card_payments capability is active — direct charges require it:

const acct = await stripe.accounts.retrieve(sellerAccountId);
const canSell = acct.capabilities?.card_payments === "active";

3. Take a payment (direct charge + commission)

The seller's account ID goes in the request options, not the params — that is what makes it a direct charge:

// app/api/checkout/route.ts
const COMMISSION_BPS = 1000; // 10.00%

const session = await stripe.checkout.sessions.create(
  {
    mode: "payment",
    line_items: [{ price: listing.stripePriceId, quantity: 1 }],
    payment_intent_data: {
      // Vertiq's cut — transferred to the platform balance
      application_fee_amount: Math.round(listing.amount * COMMISSION_BPS / 10_000),
    },
    success_url: `${process.env.APP_URL}/orders/{CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/listings/${listing.id}`,
    metadata: { listingId: listing.id, buyerId },
  },
  {
    stripeAccount: seller.stripeAccountId, // ← direct charge on the seller
    idempotencyKey: `order_${orderId}`,
  },
);

4. Receive seller webhooks

Connect events arrive at your platform endpoint with an account field naming the connected account. Register a Connect webhook endpoint and branch on it:

event = stripe.webhooks.constructEvent(body, signature, connectWebhookSecret);

const sellerAccountId = event.account; // present on Connect events
switch (event.type) {
  case "checkout.session.completed":
    // release the digital download for this seller's order
    break;
  case "account.updated":
    // capability changed — re-check card_payments before allowing listings
    break;
}

What Vertiq is still responsible for

Being off the payment liability hook is not the same as having no obligations. State these plainly rather than assuming:

Payment liability and marketplace structure carry legal and tax consequences. Confirm this design with counsel and with Stripe's Connect team before launch — this guide documents the technical mechanism, not legal advice.


Troubleshooting

Issue Fix
No such payment_intent Test vs live key mismatch — the namespaces are separate
Webhook 400 — signature mismatch Use the raw body (req.text()), not parsed JSON
confirmation_secret is undefined You expanded latest_invoice.payment_intent; it was replaced — expand latest_invoice.confirmation_secret
stripe.handleCardPayment is not a function Removed in Dahlia — use confirmCardPayment, or confirmPayment with Elements
Stripe CLI not receiving events Ensure stripe listen is running and the port matches
npm i -g @stripe/stripe-cli 404s Wrong package — it is @stripe/cli
stripe docs says key expired Run stripe login again; stripe docs needs CLI v1.43.3+
publishableKey is not set Verify NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is in .env.local

Stripe developer resources

Resource URL Use it for
Docs home https://docs.stripe.com Everything; .md suffix returns agent-ready Markdown
API reference https://docs.stripe.com/api Exact parameters and response shapes
Developer changelog https://docs.stripe.com/changelog Every API change, filterable by release
Release notes https://docs.stripe.com/changelog/dahlia Current release's breaking changes
API upgrades guide https://docs.stripe.com/upgrades How to move between versions safely
Go-live checklist https://docs.stripe.com/get-started/checklist/go-live Pre-launch review
Testing + test cards https://docs.stripe.com/testing Full card/scenario matrix
Sandboxes https://docs.stripe.com/sandboxes Isolated test environments
Keys & best practices https://docs.stripe.com/keys-best-practices Key hygiene, rotation
Restricted API keys https://docs.stripe.com/keys/restricted-api-keys Scoped keys for agents/CI
Connect https://docs.stripe.com/connect Marketplaces (Vertiq)
Accounts v2 https://docs.stripe.com/connect/accounts-v2 Current Connect account model
Connect pricing https://stripe.com/connect/pricing Platform fee structure
MCP server https://docs.stripe.com/mcp Agent tool access
Agent skills https://docs.stripe.com/skills stripe agent setup
Stripe CLI reference https://docs.stripe.com/cli Every CLI command
Workbench https://dashboard.stripe.com/workbench Live request logs, version upgrades
Status page https://status.stripe.com Incidents
stripe-node https://github.com/stripe/stripe-node Source, changelog, types
Stripe Apps marketplace https://marketplace.stripe.com Prebuilt integrations

Dashboard destinations worth bookmarking: API keys (/apikeys), Webhook endpoints (/webhooks), Workbench overview (/workbench/overview), Connect platform profile (/settings/connect/platform-setup), MCP access (/settings/mcp).


Coding agents — rules & best practices

Stripe ships first-party agent skills; install them so an agent's Stripe knowledge comes from Stripe rather than from training data:

npm i -g @stripe/cli
stripe agent setup     # installs stripe-docs, stripe-best-practices, upgrade-stripe
stripe sandbox create  # working API keys, no account registration needed

Hard rules

  1. Never pass payment_method_types. The one exception is Terminal, which requires payment_method_types: ['card_present']. Omitting it enables dynamic payment methods, so you configure payment methods from the Dashboard and Stripe shows each customer the most relevant eligible options. To restrict, use payment_method_configurations or excluded_payment_method_types — never payment_method_types.
  2. Default to a restricted key (rk_), not a secret key (sk_). Any agent, MCP client, or CI job gets the narrowest scope that works.
  3. Never enable automatic_tax: { enabled: true } without an active tax registration. Without one, Stripe calculates and collects no tax while the integration looks like tax is on — the most common Stripe Tax mistake.
  4. Pin apiVersion in code; don't inherit the account default.
  5. Tag Checkout Sessions on 2026-03-25.dahlia+ with integration_identifier (label plus an 8-random-letter suffix) so flows are comparable in the Dashboard.
  6. Fetch before you write. Use stripe docs / the MCP search_stripe_documentation tool instead of recalling an API shape — Stripe's surface moves every month.
  7. Keep human confirmation on MCP write tools. stripe_api_write can move money; treat any untrusted content in the loop as a prompt-injection risk.

Integration routing

Building… Use
One-time payments Checkout Sessions
Custom embedded payment form Checkout Sessions + Payment Element
Saving a card for later Setup Intents
Marketplace / platform (Vertiq) Accounts v2 (/v2/core/accounts)
Subscriptions / recurring Billing APIs + Checkout Sessions
Usage-based billing (new build) Metronome
Sales tax / VAT / GST Stripe Tax + Registrations API

Version drift warning

The bundled stripe-best-practices skill carries a static version table that can lag the live API. At the 2026-08-23 review the skill (v0.6.3) had caught its API-version line up to 2026-07-29.dahlia, but its Node table still read 22.4.0 while the live stripe-node source was at 22.5.0. (At the prior 2026-08-04 review it lagged on both, reporting 2026-06-24.dahlia / 22.3.0.) When the skill and the live source disagree, the live source wins — verify with:

npm view stripe version
curl -sL https://raw.githubusercontent.com/stripe/stripe-node/master/src/apiVersion.ts

codeAmani notes

Official docs:

Supabase Integration Guide

Technology: supabase · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/supabase

Insight:

Supabase is Postgres + Auth + Storage + Edge Functions in one. Its superpower is RLS — push authorization into the database so multi-tenant isolation holds even when app code is wrong. It also ships pgvector, so relational data and RAG embeddings can live in the same DB.

███████╗██╗   ██╗██████╗  █████╗ ██████╗  █████╗ ███████╗███████╗
██╔════╝██║   ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔════╝
███████╗██║   ██║██████╔╝███████║██████╔╝███████║███████╗█████╗
╚════██║██║   ██║██╔═══╝ ██╔══██║██╔══██╗██╔══██║╚════██║██╔══╝
███████║╚██████╔╝██║     ██║  ██║██████╔╝██║  ██║███████║███████╗
╚══════╝ ╚═════╝ ╚═╝     ╚═╝  ╚═╝╚═════╝ ╚═╝  ╚═╝╚══════╝╚══════╝

Supabase Integration Guide

Focus: Managing Postgres databases, Edge Functions, Auth, and Storage from Claude Code using the official Supabase MCP server and supabase CLI.

Overview

Supabase is an open-source Firebase alternative built on Postgres. It provides a hosted database, authentication, real-time subscriptions, edge functions, storage, and a REST/GraphQL API. The official @supabase/mcp-server-supabase lets Claude Code execute SQL, manage branches, deploy edge functions, read logs, apply migrations, and generate TypeScript types — all through natural language.

Here is the big picture — everything centers on one Postgres database, which makes Supabase easy to reason about:

flowchart TD
    APP["Your app<br/>supabase-js client"] --> CORE["Supabase<br/>built on Postgres"]
    CLAUDE["Claude Code<br/>via MCP server + CLI"] --> CORE
    CORE --> DB["Postgres database<br/>+ RLS policies"]
    CORE --> AUTH["Auth<br/>auth.users"]
    CORE --> STORE["Storage<br/>buckets"]
    CORE --> RT["Realtime<br/>postgres_changes"]
    CORE --> FN["Edge Functions<br/>Deno"]

Official Documentation

Resource URL
Supabase Docs https://supabase.com/docs
Supabase MCP Server https://supabase.com/docs/guides/getting-started/mcp
CLI Reference https://supabase.com/docs/reference/cli
JavaScript Client https://supabase.com/docs/reference/javascript/introduction
API Keys (publishable / secret) https://supabase.com/docs/guides/api/api-keys
Edge Functions https://supabase.com/docs/guides/functions
Database Migrations https://supabase.com/docs/guides/deployment/database-migrations

MCP Server Setup

Official Supabase MCP Server (hosted HTTP + OAuth)

The current official server is the hosted HTTP endpoint at https://mcp.supabase.com/mcp. Your MCP client logs in to Supabase over OAuth on first connect — no personal access token needed for local interactive use. (The self-hostable @supabase/mcp-server-supabase npm package still exists for CI and custom endpoints; see the CI note below.)

# Add via Claude Code CLI (streamable HTTP transport)
claude mcp add --scope project --transport http supabase "https://mcp.supabase.com/mcp"

.mcp.json Configuration

{
  "mcpServers": {
    "supabase": {
      "type": "http",
      "url": "https://mcp.supabase.com/mcp?project_ref=<project-ref>&read_only=true&features=database,docs"
    }
  }
}

Configuration options are passed as URL query params on the endpoint:

Param Purpose
project_ref=<ref> Project scoping — restrict the server to a single project (drops account-level tools)
read_only=true Read-only mode — excludes every mutating tool (no execute_sql writes, no apply_migration, no deploy_edge_function)
features=database,docs,... Restrict to specific feature groups (account, database, debugging, development, functions, branching, storage, docs)

Security (codeAmani default): never point the MCP server at a production project. Scope it (project_ref), run it read-only unless you are actively applying changes, and prefer a development branch. The server executes SQL as an elevated role, so an injected instruction in your data could otherwise mutate real rows. See the MCP security best practices.

CI / headless / self-host: use a personal access token instead of OAuth. Pass it as Authorization: Bearer ${SUPABASE_ACCESS_TOKEN} to the hosted endpoint, or run the @supabase/mcp-server-supabase npm package locally with --access-token. When running Supabase locally via the CLI, a limited MCP server is served at http://localhost:54321/mcp. Generate a PAT at: https://supabase.com/dashboard/account/tokens

Available MCP Tools

Grouped by feature (the features param toggles whole groups). Read-only mode hides the mutating tools.

Group Tools
Account list_projects, get_project, create_project, pause_project, restore_project, list_organizations, get_organization, get_cost, confirm_cost
Database list_tables, list_extensions, list_migrations, apply_migration, execute_sql
Debugging query_logs, get_advisors
Development get_project_url, get_publishable_keys, generate_typescript_types
Functions list_edge_functions, get_edge_function, deploy_edge_function
Branching create_branch, list_branches, delete_branch, merge_branch, reset_branch, rebase_branch
Storage list_storage_buckets, get_storage_config, update_storage_config
Docs search_docs

Naming has changed since older guides: logs are now query_logs (not get_logs), get_advisors surfaces security/performance lints, search_docs queries the Supabase docs, and get_publishable_keys returns the new publishable API keys (see below). There is no longer a standalone describe_table_schema tool — list_tables returns column types and constraints.


CLI Integration

Installation

# npm (global)
npm install -g supabase

# macOS (brew)
brew install supabase/tap/supabase

# Windows (Scoop)
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabase

Authentication

supabase login
# Or set token:
export SUPABASE_ACCESS_TOKEN=sbp_...

Key Commands

# Link to an existing project
supabase link --project-ref your-project-ref

# Start local Supabase stack (Docker required)
supabase start

# Stop local stack
supabase stop

# Check local status
supabase status

# Database migrations
supabase migration new add_users_table
supabase db push                    # push migrations to linked project
supabase db pull                    # pull remote schema to local
supabase db reset                   # reset local DB and re-apply migrations
supabase db diff                    # show diff between local and remote

# Generate TypeScript types from schema
supabase gen types typescript --linked > src/types/database.ts

# Edge Functions
supabase functions new my-function
supabase functions serve my-function  # local dev
supabase functions deploy my-function --no-verify-jwt

# Storage (CLI v2 — these subcommands are behind --experimental)
supabase storage ls --experimental --linked
supabase storage cp ./file.pdf ss:///my-bucket/file.pdf --experimental --linked

# Logs
supabase logs --project-ref your-ref

Client SDK Integration

JavaScript / TypeScript

npm install @supabase/supabase-js
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./types/database"; // generated types

const supabase = createClient<Database>(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
);

// Query with full type safety
const { data, error } = await supabase
  .from("users")
  .select("id, email, created_at")
  .eq("active", true)
  .order("created_at", { ascending: false })
  .limit(10);

// Insert
const { error: insertError } = await supabase
  .from("posts")
  .insert({ title: "Hello", content: "World", user_id: userId });

// Real-time subscription
const channel = supabase
  .channel("db-changes")
  .on("postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, (payload) => {
    console.log("New message:", payload.new);
  })
  .subscribe();

Environment Variables

New API keys (2025 → current). Supabase has replaced the JWT-based anon / service_role keys with publishable (sb_publishable_..., browser-safe) and secret (sb_secret_..., server-only) keys. The legacy JWT keys still work but are scheduled for deprecation by end of 2026 — new projects should adopt the new keys now. The two schemes run side by side, so you can migrate incrementally. Generate the new keys in Dashboard → Project Settings → API Keys.

# Client-side (safe to expose in the frontend)
NEXT_PUBLIC_SUPABASE_URL=https://your-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...   # NEW — browser-safe, RLS-respecting
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...                    # legacy JWT key (still valid; sunset end of 2026)

# Server-side only (never expose in the frontend)
SUPABASE_SECRET_KEY=sb_secret_...                          # NEW — bypasses RLS; replaces service_role
# SUPABASE_SERVICE_ROLE_KEY=eyJ...                         # legacy JWT key (still valid; sunset end of 2026)
SUPABASE_DB_PASSWORD=...

# Direct Postgres connection string (for migrations/scripts)
DATABASE_URL=postgresql://postgres:[password]@db.your-ref.supabase.co:5432/postgres
DIRECT_URL=postgresql://postgres:[password]@db.your-ref.supabase.co:5432/postgres

# CLI/MCP authentication (CI / headless only — interactive MCP uses OAuth)
SUPABASE_ACCESS_TOKEN=sbp_...

The publishable key is the browser client's key and still respects RLS (it is auth.uid() = NULL until a user signs in). The secret key carries the elevated, RLS-bypassing role — treat it exactly like the old service_role key: server-only, never committed, never shipped to the browser.


Automation Workflows

Claude Code Hook: Auto-generate Types After Migration

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'supabase db push\\|migration'; then supabase gen types typescript --linked > src/types/database.ts && echo 'Types regenerated'; fi"
          }
        ]
      }
    ]
  }
}

Slash Command: Database Inspection

.claude/commands/db-inspect.md:

Inspect the Supabase database for table $ARGUMENTS.

1. Use the Supabase MCP tool `list_tables` to get the schema (column types and constraints) for table $ARGUMENTS
2. Use `execute_sql` to run: SELECT COUNT(*) FROM $ARGUMENTS
3. Use `execute_sql` to get a sample of 5 rows: SELECT * FROM $ARGUMENTS LIMIT 5
4. Report: column names/types, row count, sample data, and any missing indexes or constraints

Usage: /project:db-inspect users

Migration-Safe Database Changes

The example below enables RLS so users only see their own posts. Here is how that check plays out on every query — once it is in place, your authorization holds even if app code slips:

sequenceDiagram
    participant C as "Client"
    participant P as "Postgres + RLS"
    participant T as "posts table"
    C->>P: "select on posts as auth.uid"
    P->>P: "check policy auth.uid = user_id"
    alt "policy passes"
        P->>T: "read matching rows"
        T-->>C: "return user own posts"
    else "policy blocks"
        P-->>C: "return no rows"
    end
-- supabase/migrations/20250512000000_add_posts.sql
CREATE TABLE IF NOT EXISTS posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  title TEXT NOT NULL CHECK (char_length(title) <= 200),
  content TEXT,
  published_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Enable Row Level Security
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see their own posts
CREATE POLICY "users_own_posts" ON posts
  FOR ALL USING (auth.uid() = user_id);

-- Index for performance
CREATE INDEX IF NOT EXISTS posts_user_id_idx ON posts (user_id);
CREATE INDEX IF NOT EXISTS posts_published_at_idx ON posts (published_at DESC);

Apply: supabase db push or via MCP apply_migration.


Auth & sessions: how auth.uid() is populated

RLS policies like auth.uid() = user_id only work if a user JWT reaches Postgres. Here is the chain: a user signs in, Supabase Auth issues a JWT, and supabase-js sends it as the Authorization: Bearer header (or a session cookie in SSR). PostgREST decodes that JWT into the request's auth.uid() and auth.jwt(), which your policies then evaluate. The key you ship matters: the anon key (new name: publishable key, sb_publishable_...) is a public, RLS-respecting key safe for the browser — auth.uid() is NULL until a user signs in. The service_role key (new name: secret key, sb_secret_...) carries an elevated claim that bypasses RLS entirely, so it is server-only and treats every row as accessible.

sequenceDiagram
    participant B as "Browser<br/>anon key + user JWT"
    participant PR as "PostgREST"
    participant PG as "Postgres + RLS"
    B->>PR: "request with Bearer JWT"
    PR->>PG: "set role · auth.uid from JWT"
    PG->>PG: "evaluate policy auth.uid = user_id"
    PG-->>B: "only this user's rows"

On the server (Next.js App Router), use @supabase/ssr's createServerClient with the anon key plus cookie accessors so the user's session flows from cookies into queries — keeping RLS in force. Always verify identity with supabase.auth.getUser(), which contacts the Auth server to revalidate the JWT, never getSession(), which only reads cookies and returns an unverified user that a malicious client could spoof.

// lib/supabase-server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createSupabaseServerClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // anon key, NOT service_role
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Called from a Server Component — cookie writes are handled by middleware.
          }
        },
      },
    },
  );
}

// In a Server Component / Route Handler:
const supabase = await createSupabaseServerClient();
const {
  data: { user },
  error,
} = await supabase.auth.getUser(); // verifies the JWT with the Auth server

if (!user) {
  // not authenticated — redirect or return 401
}
// Queries run as this user; auth.uid() now drives RLS automatically.
const { data: posts } = await supabase.from("posts").select("*");

Security gotcha: Never expose SUPABASE_SERVICE_ROLE_KEY to the client or use it in code that runs in the browser — it bypasses every RLS policy. Reserve it for trusted server-only admin scripts (cron jobs, webhooks). For user-facing server code, use the anon key + getUser() so RLS stays in effect.

Canonical docs: Server-Side Auth (Next.js) · Creating a server client


Common Use Cases

Use Case Approach
Schema changes supabase migration new + db push
Type generation supabase gen types typescript --linked
Edge Function deploy supabase functions deploy or MCP
Debug slow queries MCP execute_sql with EXPLAIN ANALYZE
Branch for feature dev MCP create_branch + merge_branch
Read production logs MCP query_logs

Troubleshooting

Issue Fix
supabase start fails Ensure Docker Desktop is running
Migration conflict Run supabase db pull to sync first
RLS blocking queries Use service_role key for admin scripts
Types out of sync Re-run supabase gen types typescript --linked
Connection refused Check supabase status — local stack may be stopped
PAT expired Regenerate at supabase.com/dashboard/account/tokens

Official docs:

Together AI Integration Guide

Technology: together-ai · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/together-ai

Insight:

Together AI is the open-model marketplace tier — 200+ open-weight LLMs, vision, image (FLUX) and embedding models behind one OpenAI-compatible endpoint. Swap baseURL to https://api.together.ai/v1 and your existing OpenAI / AI-SDK code routes to Llama, Qwen, DeepSeek or gpt-oss with only a model-string change. Use it when you want open-weight flexibility, image generation, or fine-tuning without leaving the OpenAI call shape.

████████╗ ██████╗  ██████╗ ███████╗████████╗██╗  ██╗███████╗██████╗      █████╗ ██╗
╚══██╔══╝██╔═══██╗██╔════╝ ██╔════╝╚══██╔══╝██║  ██║██╔════╝██╔══██╗    ██╔══██╗██║
   ██║   ██║   ██║██║  ███╗█████╗     ██║   ███████║█████╗  ██████╔╝    ███████║██║
   ██║   ██║   ██║██║   ██║██╔══╝     ██║   ██╔══██║██╔══╝  ██╔══██╗    ██╔══██║██║
   ██║   ╚██████╔╝╚██████╔╝███████╗   ██║   ██║  ██║███████╗██║  ██║    ██║  ██║██║
   ╚═╝    ╚═════╝  ╚═════╝ ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝    ╚═╝  ╚═╝╚═╝

Together AI Integration Guide

Focus: Serverless inference for 200+ open-weight models — chat, vision, embeddings, rerank, and FLUX image generation — through an OpenAI-compatible API plus a native SDK.

Overview

Together AI is an inference platform for open-source models. A single API key unlocks chat/completion models (Llama, Qwen, DeepSeek, gpt-oss, MiniMax), multimodal vision models, embedding + rerank models, audio (speech-to-text / TTS), and FLUX image + video generation — all billed per token / per image with no GPU management. The API is OpenAI-compatible, so existing OpenAI-SDK code works after changing the API key and base URL; for richer features (images, rerank, fine-tuning, dedicated endpoints) there's a first-party together-ai / together SDK.

For codeAmani, Together AI is the open-weight lever: it sits alongside Anthropic (frontier quality) and DeepSeek (budget reasoning) as the place to reach for open models, image generation, or a fine-tuned house model — without rewriting integration code.

The same OpenAI call shape simply points at Together and fans out to any open model:

flowchart LR
  A["Your app code"] --> B["OpenAI SDK<br/>baseURL · api.together.ai/v1"]
  B --> C{"Which model string?"}
  C -->|"chat"| D["Llama 3.3 · Qwen · DeepSeek<br/>gpt-oss · MiniMax"]
  C -->|"vision"| E["Qwen2.5-VL · Llama-Vision"]
  C -->|"embeddings"| F["multilingual-e5 · BGE"]
  C -->|"images"| G["FLUX.2 / FLUX.1"]

Official Documentation

Resource URL
Docs home / Quickstart https://docs.together.ai/docs/quickstart
OpenAI compatibility https://docs.together.ai/docs/inference/openai-compatibility
API reference https://docs.together.ai/reference
Models list https://docs.together.ai/docs/serverless-models
Dedicated endpoints https://docs.together.ai/docs/dedicated-endpoints

Setup

Option A — OpenAI SDK (drop-in)

Together's API mirrors OpenAI's REST schema. Keep the OpenAI SDK; change the key and base URL.

npm install openai
import OpenAI from "openai";

const together = new OpenAI({
  apiKey: process.env.TOGETHER_API_KEY!,
  baseURL: "https://api.together.ai/v1",
});

const res = await together.chat.completions.create({
  model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
  messages: [{ role: "user", content: "Hello!" }],
});
console.log(res.choices[0].message.content);

Option B — Native Together SDK (full surface)

Use the first-party SDK for images, rerank, fine-tuning, batches, and dedicated endpoints. The client reads TOGETHER_API_KEY from the environment by default.

npm install together-ai     # Node / TypeScript
pip install together        # Python
import Together from "together-ai";

const client = new Together(); // picks up TOGETHER_API_KEY

const chat = await client.chat.completions.create({
  model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
  messages: [{ role: "user", content: "Say this is a test!" }],
});
console.log(chat.choices);
from together import Together

client = Together()  # picks up TOGETHER_API_KEY

resp = client.chat.completions.create(
    model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
    messages=[{"role": "user", "content": "What is 2 + 2?"}],
)
print(resp.choices[0].message.content)

Core Patterns

Streaming

const stream = await client.chat.completions.create({
  model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
  messages: [{ role: "user", content: prompt }],
  stream: true,
});

for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
// Cancel anytime with stream.controller.abort()

Vision (multimodal)

resp = client.chat.completions.create(
    model="Qwen/Qwen2.5-VL-72B-Instruct",
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "Describe this image."},
            {"type": "image_url", "image_url": {"url": "https://example.com/receipt.png"}},
        ],
    }],
)
print(resp.choices[0].message.content)

Embeddings

resp = client.embeddings.create(
    model="intfloat/multilingual-e5-large-instruct",
    input=["The cat sat on the mat", "A dog played in the park"],
)
for row in resp.data:
    print(len(row.embedding), "dimensions")

Image generation (FLUX)

const image = await client.images.generate({
  model: "black-forest-labs/FLUX.2-pro",
  prompt: "A vibrant Nairobi street market at golden hour, photorealistic",
  width: 1024,
  height: 768,
});
console.log(image.data[0].url); // hosted URL (or b64_json if requested)
// Budget / fastest: model "black-forest-labs/FLUX.1-schnell" with steps: 4

Next.js App Router streaming route

// app/api/ai/together/route.ts
import OpenAI from "openai";
import { NextRequest } from "next/server";

const together = new OpenAI({
  apiKey: process.env.TOGETHER_API_KEY!,
  baseURL: "https://api.together.ai/v1",
});

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  const stream = await together.chat.completions.create({
    model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
    stream: true,
    messages,
  });

  const encoder = new TextEncoder();
  return new Response(
    new ReadableStream({
      async start(controller) {
        for await (const chunk of stream) {
          const text = chunk.choices[0]?.delta?.content ?? "";
          if (text) controller.enqueue(encoder.encode(text));
        }
        controller.close();
      },
    }),
    { headers: { "Content-Type": "text/plain; charset=utf-8" } },
  );
}

Serverless vs. Dedicated Endpoints

Together runs models two ways. Pick by traffic shape:

flowchart TD
  A["Need to run an open model"] --> B{"Predictable<br/>high volume?"}
  B -->|"no — bursty / dev"| C["Serverless<br/>pay-per-token, shared pool"]
  B -->|"yes — steady QPS"| D["Dedicated endpoint<br/>reserved GPUs, flat hourly"]
  C --> E["Zero ops, instant, per-token billing"]
  D --> F["Stable latency, no rate-limit contention"]

AI Routing: Where Together AI Fits

In codeAmani's routing strategy, Together AI is the open-model + image tier:

Task Recommended provider/model
Complex reasoning, agents Anthropic claude-sonnet-4-6
Budget chain-of-thought DeepSeek deepseek-reasoner
Open-weight chat at scale Together meta-llama/Llama-3.3-70B-Instruct-Turbo
Open-weight reasoning Together deepseek-ai/DeepSeek-V3.1 or openai/gpt-oss-120b
Vision / document understanding Together Qwen/Qwen2.5-VL-72B-Instruct
Image generation Together black-forest-labs/FLUX.2-pro (or FLUX.1-schnell for speed)
Fast structured JSON OpenAI gpt-4o
// lib/ai.ts — Together slots in as the open-model tier
function selectModel(task: "reason" | "openchat" | "vision" | "image") {
  switch (task) {
    case "reason":   return { provider: "anthropic", model: "claude-sonnet-4-6" };
    case "openchat": return { provider: "together", model: "meta-llama/Llama-3.3-70B-Instruct-Turbo" };
    case "vision":   return { provider: "together", model: "Qwen/Qwen2.5-VL-72B-Instruct" };
    case "image":    return { provider: "together", model: "black-forest-labs/FLUX.2-pro" };
  }
}

Because Together is OpenAI-shaped, the resilient-fallback pattern from the DeepSeek guide applies unchanged — retry transient 429/5xx with jittered backoff, then fall back to Anthropic Claude.


Environment Variables

# Required
TOGETHER_API_KEY=...

# Optional — the native SDK reads TOGETHER_BASE_URL (defaults to
# https://api.together.ai/v1). Point it at a self-hosted / proxied
# deployment without touching code — the open-weights on-ramp story.
TOGETHER_BASE_URL=https://api.together.ai/v1

# Base URL is set in code (OpenAI SDK): https://api.together.ai/v1
# The native SDK reads TOGETHER_API_KEY automatically.

codeAmani Notes


Troubleshooting

Issue Fix
401 Unauthorized Verify TOGETHER_API_KEY; the native SDK trims surrounding quotes, the OpenAI SDK does not — store the raw key
model not found Use exact IDs from the models list (e.g. meta-llama/Llama-3.3-70B-Instruct-Turbo), case-sensitive
Need rerank via OpenAI SDK Rerank is native-together-ai-SDK-only; image generation + embeddings are on the OpenAI-compat surface now, but Together-specific image params (steps, img-to-img image_url) still need the native SDK
429 rate limited Back off + retry, or move steady traffic to a dedicated endpoint
Slow first token on rare models Cold serverless start — pre-warm or use a dedicated endpoint for production

Official docs:

Twilio Integration Guide

Technology: twilio · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/twilio

Insight:

Twilio is the US/global comms channel — SMS, MMS, Voice, and OTP via the managed Verify API. It's advertised on motionstackstudios.com, but for the Kenya market the house default is Africa's Talking (better local coverage and pricing). Reach for Twilio on US/global builds and Florida healthcare clients; before choosing, consult the codeAmani-tech-stack MCP. For OTP, prefer Verify over rolling your own code store, and always validate inbound webhook signatures.

████████╗██╗    ██╗██╗██╗     ██╗ ██████╗
╚══██╔══╝██║    ██║██║██║     ██║██╔═══██╗
   ██║   ██║ █╗ ██║██║██║     ██║██║   ██║
   ██║   ██║███╗██║██║██║     ██║██║   ██║
   ██║   ╚███╔███╔╝██║███████╗██║╚██████╔╝
   ╚═╝    ╚══╝╚══╝ ╚═╝╚══════╝╚═╝ ╚═════╝

Twilio Integration Guide

Focus: When and how codeAmani uses Twilio for US/global communications — Programmable Messaging (SMS/MMS), the managed Verify API for OTP/phone verification, Voice basics, secure inbound webhooks with request-signature validation, and WhatsApp via Twilio. The Kenya default is Africa's Talking; Twilio is the choice for US/global SMS, voice, and OTP (e.g. Florida healthcare clients).

Overview

Twilio is a REST cloud-communications platform with an official twilio Node.js helper library. Authenticate with your Account SID + Auth Token from console.twilio.com. The SDK exposes the whole platform — client.messages (SMS/MMS), client.verify.v2 (OTP/Verify), client.calls (Voice), plus Conversations, Lookup, and more. SMS and WhatsApp share the same messages.create call; only the address format changes (+1555… vs whatsapp:+1555…).

Twilio is advertised on motionstackstudios.com for SMS/voice, but it is not the global default. For the Kenya market the house default is Africa's Talking — better local network coverage, local short codes/sender IDs, and pricing (see the africas-talking guide). Twilio earns the slot for US and international reach, Voice, and managed OTP verification, which is why it backs the Florida healthcare clients.

Here is the canonical OTP flow — the Verify API holds the code, so the app never stores or compares it:

sequenceDiagram
  participant U as "User"
  participant APP as "Your app (Next.js API route)"
  participant V as "Twilio Verify API"
  U->>APP: "Enter phone number"
  APP->>V: "verifications.create {to, channel: sms}"
  V->>U: "SMS - Your code is 482915"
  U->>APP: "Submit code 482915"
  APP->>V: "verificationChecks.create {to, code}"
  V-->>APP: "status: approved | pending"
  APP-->>U: "Verified - issue session"

Check first. Before adding Twilio to a build, query the codeAmani-tech-stack MCP (search_guides / get_guide) and confirm the audience. Kenya / East Africa → Africa's Talking. US / global, or Voice, or managed OTP → Twilio. Picking the wrong provider means worse delivery rates and higher per-message cost.

Official Documentation

Resource URL
Twilio Docs (root) https://www.twilio.com/docs
Programmable Messaging https://www.twilio.com/docs/messaging
Verify API (OTP) https://www.twilio.com/docs/verify/api
Programmable Voice https://www.twilio.com/docs/voice
Webhooks Security (signature) https://www.twilio.com/docs/usage/webhooks/webhooks-security
WhatsApp via Twilio https://www.twilio.com/docs/whatsapp
Node helper library https://github.com/twilio/twilio-node

No first-party Twilio MCP server is in the house stack. Drive Twilio from Claude Code via the twilio Node SDK (below) and, optionally, the Twilio CLI. For WhatsApp at scale (templates, message approval, the Cloud API), there is a dedicated whatsapp-business-api guide — Twilio's WhatsApp is the simpler managed on-ramp.


1. Credentials & install

Get the Account SID (AC…) and Auth Token from the console dashboard. For Verify, create a Verify Service in the console and copy its Service SID (VA…). For Messaging, buy a phone number (or set up a Messaging Service, MG…).

npm install twilio   # v6.x — requires Node.js >= 20 (v6.0.0 dropped Node < 20)
// lib/twilio.ts
import twilio from "twilio";

export const client = twilio(
  process.env.TWILIO_ACCOUNT_SID!,
  process.env.TWILIO_AUTH_TOKEN!, // server-side only — never ship to the browser
);

Phone numbers must be E.164 (+15619991234, +254711223344). Unlike Africa's Talking, Twilio always wants the leading +. The Auth Token is a full-account secret — keep it server-side (.env.local / Vercel env, mirror to ENV_MASTER.md).

2. Send an SMS (Programmable Messaging)

import { client } from "@/lib/twilio";

export async function sendSms(to: string, body: string) {
  const message = await client.messages.create({
    to,                               // E.164, e.g. "+15619991234"
    from: process.env.TWILIO_PHONE_NUMBER!, // a Twilio number you own, or use messagingServiceSid
    body,
  });
  return message.sid; // "SM…" — accepted; final state arrives via the status webhook
}

For higher deliverability and number pooling, send through a Messaging Service instead of a single from number, and register a status callback for delivery state:

await client.messages.create({
  to: "+15619991234",
  messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!, // "MG…"
  body: "Your codeAmani appointment is confirmed for Tue 10:00 AM.",
  statusCallback: "https://app.example.com/api/twilio/status", // queued → sent → delivered
});

MMS: add mediaUrl: ["https://…/file.png"] to attach images/PDFs (US/Canada numbers). The returned message.status is queued/accepted — that means accepted for sending, not delivered. Track real delivery via the status callback, and watch for undelivered / failed with an errorCode (e.g. 30007 carrier filtering, 21610 recipient opted out).

3. OTP / phone verification — the Verify API (preferred)

Do not roll your own OTP (generating, storing, expiring, and rate-limiting codes is a security footgun). Twilio Verify manages code generation, delivery, expiry, retries, and fraud controls server-side. You only call start and check.

// lib/verify.ts
import { client } from "@/lib/twilio";

const SERVICE = process.env.TWILIO_VERIFY_SERVICE_SID!; // "VA…"

/** Start: send a code over SMS (or "call", "whatsapp", "email"). */
export async function startVerification(to: string) {
  const v = await client.verify.v2
    .services(SERVICE)
    .verifications.create({ to, channel: "sms" });
  return v.status; // "pending"
}

/** Check: verify the code the user entered. */
export async function checkVerification(to: string, code: string) {
  const check = await client.verify.v2
    .services(SERVICE)
    .verificationChecks.create({ to, code });
  return check.status === "approved"; // else: pending | canceled | max_attempts_reached | failed | expired
}

Wire it into a pair of Next.js route handlers — start on submit, check on confirm:

// app/api/verify/start/route.ts
import { NextResponse } from "next/server";
import { startVerification } from "@/lib/verify";

export async function POST(req: Request) {
  const { phone } = await req.json();
  await startVerification(phone);
  return NextResponse.json({ ok: true });
}

// app/api/verify/check/route.ts
import { NextResponse } from "next/server";
import { checkVerification } from "@/lib/verify";

export async function POST(req: Request) {
  const { phone, code } = await req.json();
  const approved = await checkVerification(phone, code);
  if (!approved) return NextResponse.json({ ok: false }, { status: 401 });
  // approved → mint your session / mark the phone verified
  return NextResponse.json({ ok: true });
}

Gotcha: never branch on a thrown error to mean "wrong code." A wrong code returns status: "pending" (or max_attempts_reached), not an exception. Only "approved" means verified. After max_attempts_reached you must start a new verification.

4. Voice basics

Outbound calls use TwiML — either a hosted URL that returns TwiML, or inline twiml:

const call = await client.calls.create({
  to: "+15619991234",
  from: process.env.TWILIO_PHONE_NUMBER!,
  twiml: "<Response><Say voice=\"Polly.Joanna\">This is a codeAmani appointment reminder.</Say></Response>",
});
return call.sid; // "CA…"

For inbound calls and IVR, point your Twilio number's Voice webhook at a route that returns TwiML built with the SDK's VoiceResponse:

import twilio from "twilio";

const vr = new twilio.twiml.VoiceResponse();
const gather = vr.gather({ numDigits: 1, action: "/api/twilio/voice/handle", method: "POST" });
gather.say("Press 1 for appointments, 2 for billing.");
// res.type("text/xml").send(vr.toString());

Verify can also deliver OTP over a call (channel: "call") for users who can't receive SMS.

5. Secure inbound webhooks (signature validation) — required

Every inbound Twilio request (incoming SMS, delivery status, voice events) hits a public URL, so you must authenticate it. Twilio signs each request and sends the signature in the X-Twilio-Signature header; validate it with twilio.validateRequest against the full request URL and the parsed form-urlencoded body. This ties into the house webhooks guide (verify-then-process, treat the body as untrusted).

// lib/twilio-webhook.ts  — framework-agnostic verification
import twilio from "twilio";

export function isValidTwilioRequest(
  signature: string,
  url: string,                       // the EXACT public URL Twilio called (incl. https + path + query)
  params: Record<string, string>,    // parsed application/x-www-form-urlencoded body
): boolean {
  return twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN!, signature, url, params);
}

Inbound-SMS handler as a Next.js App Router route (Twilio POSTs application/x-www-form-urlencoded):

// app/api/twilio/inbound/route.ts
import { NextResponse } from "next/server";
import { isValidTwilioRequest } from "@/lib/twilio-webhook";

export async function POST(req: Request) {
  const signature = req.headers.get("x-twilio-signature") ?? "";
  const form = await req.formData();
  const params = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]));

  // The signed URL must match what Twilio called. Behind Vercel/proxies, build it from
  // forwarded headers (https + host) — NOT req.url, which may show the internal origin.
  const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
  const url = `https://${host}/api/twilio/inbound`;

  if (!isValidTwilioRequest(signature, url, params)) {
    return new NextResponse("Invalid signature", { status: 403 });
  }

  const from = params.From;
  const body = params.Body;
  // ... process the message (untrusted input) ...

  // Reply with TwiML (empty <Response/> = no auto-reply)
  return new NextResponse("<Response><Message>Thanks, we got it.</Message></Response>", {
    status: 200,
    headers: { "Content-Type": "text/xml" },
  });
}

On Express, the SDK ships a ready-made middleware:

const twilio = require("twilio");
const express = require("express");
const app = express();

app.use(express.urlencoded({ extended: false })); // MUST run before validation
app.post(
  "/twilio/inbound",
  twilio.webhook(), // reads TWILIO_AUTH_TOKEN, validates X-Twilio-Signature, 403s otherwise
  (req, res) => {
    const reply = new twilio.twiml.MessagingResponse();
    reply.message("Thanks, we got it.");
    res.type("text/xml").send(reply.toString());
  },
);

Gotcha — the signed URL must match byte-for-byte. Twilio signs the exact URL it requested (scheme, host, path, and sorted POST params). Behind Vercel/Cloudflare the internal req.url host differs from the public one, so validation fails with a correct token. Build the URL from x-forwarded-host + https, or set an explicit public URL. Also: express.urlencoded (or formData()) must parse the body before you validate — an unparsed body yields an empty params and a guaranteed mismatch.

6. WhatsApp via Twilio

Twilio is the simplest on-ramp to WhatsApp: the same messages.create, with both numbers prefixed whatsapp:. Use the Sandbox for dev; production requires a WhatsApp-enabled sender and pre-approved content templates for business-initiated (outside the 24-hour window) messages.

await client.messages.create({
  to: "whatsapp:+15619991234",
  from: "whatsapp:+14155238886", // your WhatsApp sender (Sandbox number in dev)
  body: "Your codeAmani appointment is confirmed.",
});

For full WhatsApp Business needs — template management, the Meta Cloud API, opt-in flows — see the dedicated whatsapp-business-api guide. Use Twilio's WhatsApp when you want one provider/billing surface alongside your SMS and Voice.


Environment Variables

# Account auth (server-side only — the Auth Token is a full-account secret)
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx   # NEVER ship to the client bundle

# Messaging
TWILIO_PHONE_NUMBER=+15619991234                    # a Twilio number you own (E.164)
TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  # optional: number pool / sender

# Verify (OTP) — create the Service in the console, copy its SID
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Add these to ENV_MASTER.md and each project's .env.example. Validate inbound webhooks with TWILIO_AUTH_TOKEN; never expose it to the browser. Prefer per-environment Verify Services so dev/test traffic and rate limits stay isolated from production.

CLI Integration (optional)

npm install -g twilio-cli
twilio login                                   # stores credentials in the keychain
twilio api:core:messages:create \
  --from "$TWILIO_PHONE_NUMBER" --to "+15619991234" --body "Hello from codeAmani"
twilio phone-numbers:list                      # list owned numbers

For local webhook testing, tunnel your dev server (ngrok http 3000) and set the public HTTPS URL as the number's messaging/voice webhook — the same workflow as the M-Pesa/AT callback testing in the africas-talking guide.


Common Use Cases

Use Case Approach
Kenya / East Africa SMS Africa's Talking (house default) — better local coverage/pricing
US / global SMS, MMS Twilio client.messages.create (E.164 to, Twilio from or Messaging Service)
OTP / phone verification Verify API — verifications.create then verificationChecks.create (never roll your own)
High-volume / multi-number SMS Messaging Service (messagingServiceSid) + status callback
Outbound voice reminder client.calls.create with twiml (<Say>)
Inbound SMS / voice IVR Webhook route returning TwiML, signature-validated
WhatsApp (managed) Twilio WhatsApp (whatsapp: prefix); full Business API → whatsapp-business-api guide
Delivery tracking statusCallback webhook (queued → sent → delivered / undelivered)

Troubleshooting

Issue Fix
validateRequest always returns false Sign the exact public URL Twilio called — rebuild from x-forwarded-host + https behind a proxy, not internal req.url
Webhook 403 with correct token Body wasn't parsed before validation — run express.urlencoded / req.formData() first so params is populated
Verify "wrong code" not raising an error A bad code returns status: "pending"/max_attempts_reached, not an exception — only "approved" means verified
max_attempts_reached on check Start a new verification; the old one is spent
SMS undelivered / errorCode 30007 Carrier filtering — register a Messaging Service / A2P 10DLC brand+campaign for US traffic
21610 recipient opted out The number replied STOP — Twilio blocks until they reply START; respect opt-out
21408/21606 permission or geo error Enable the destination region (Geo Permissions) or use a from number that supports the channel
Wrong audience / poor delivery in Kenya You picked Twilio for an East Africa build — switch to Africa's Talking (consult the codeAmani-tech-stack MCP)
WhatsApp message not sent outside 24h window Business-initiated messages need a pre-approved content template; free-form only inside the 24-hour customer window
Auth Token leaked / rotated Rotate in the console; the secondary token lets you roll without downtime — update TWILIO_AUTH_TOKEN everywhere

Official docs:

Upstash (Redis + QStash) Integration Guide

Technology: upstash · Category: database · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/upstash

Insight:

Upstash is HTTP-based Redis + QStash, so both run on Vercel Edge where TCP clients like ioredis can't. Use Redis for caching and rate-limiting, QStash for background jobs — its guaranteed-delivery retries pair perfectly with the M-Pesa idempotency pattern, letting the Daraja callback return fast.

██╗   ██╗██████╗ ███████╗████████╗ █████╗ ███████╗██╗  ██╗
██║   ██║██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║  ██║
██║   ██║██████╔╝███████╗   ██║   ███████║███████╗███████║
██║   ██║██╔═══╝ ╚════██║   ██║   ██╔══██║╚════██║██╔══██║
╚██████╔╝██║     ███████║   ██║   ██║  ██║███████║██║  ██║
 ╚═════╝ ╚═╝     ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝

Upstash (Redis + QStash) Integration Guide

Focus: Serverless Redis and the QStash message queue — both accessed over HTTP/REST, so they run on Vercel Edge / serverless functions where persistent TCP connections (e.g. ioredis) are not allowed. Pay-per-request pricing and scale-to-zero suit codeAmani's low-volume SME workloads.

Overview

Upstash gives two server-side primitives codeAmani uses:

Both authenticate with a token and require no persistent connection — ideal for the Next.js App Router / Vercel functions in our stack. Python SDKs (upstash-redis, qstash) exist if a service is written in Python.

Sibling products (same HTTP/REST contract). Upstash also ships Vector (@upstash/vector — serverless ANN for RAG) and Workflow (durable, multi-step serverless functions built on top of QStash). Upstash Kafka was discontinued on 2025-03-11 — Upstash steers former Kafka users to QStash / Workflow, so do not reach for Upstash Kafka in new builds.

Here is the big picture — both primitives reached over HTTP/REST from the same edge function:

flowchart LR
  APP["Vercel Edge<br/>Next.js function"] -->|"REST + token"| REDIS["Upstash Redis<br/>cache · sessions · rate-limit"]
  APP -->|"REST + token"| QSTASH["QStash<br/>queue + scheduler"]
  QSTASH -->|"deliver + retry"| WORKER["Receiver route<br/>background job"]

Official Documentation

Resource URL
Redis docs https://upstash.com/docs/redis
Redis TS SDK quickstart https://upstash.com/docs/redis/sdks/ts/getstarted
Rate limiting https://upstash.com/docs/redis/sdks/ratelimit-ts/overview
QStash docs https://upstash.com/docs/qstash
QStash Next.js quickstart https://upstash.com/docs/qstash/quickstarts/vercel-nextjs
Console https://console.upstash.com

1. Get credentials

Create a database / QStash instance at console.upstash.com, then copy the REST credentials into .env.local (and the Vercel project's env vars):

# Redis
UPSTASH_REDIS_REST_URL=https://<region>-<name>.upstash.io
UPSTASH_REDIS_REST_TOKEN=<token>
# QStash
QSTASH_TOKEN=<token>
QSTASH_CURRENT_SIGNING_KEY=<key>   # for verifying incoming messages
QSTASH_NEXT_SIGNING_KEY=<key>

The Vercel ↔ Upstash integration injects these automatically. Server-side only — never ship a token to the browser.

2. Install

npm install @upstash/redis @upstash/qstash   # JS/TS
# optional: npm install @upstash/ratelimit @upstash/vector
pip install upstash-redis qstash             # Python

Current versions (verified 2026-08-23): @upstash/redis 1.38.2, @upstash/qstash 2.11.3, @upstash/ratelimit 2.0.8, @upstash/vector 1.2.3; Python upstash-redis 1.7.0, qstash 3.4.0. Pin a range and re-check before a major bump — the API surface used here (Redis.fromEnv, Ratelimit.slidingWindow, Client.publishJSON, verifySignatureAppRouter) has been stable across these releases.

3. Redis client

import { Redis } from "@upstash/redis";

// Reads UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN from the env
export const redis = Redis.fromEnv();

await redis.set("foo", "bar");
const bar = await redis.get<string>("foo");

The SDK auto-JSON.stringifys non-string values on set and parses them back on get<T>(), so you can store and read plain objects directly.

4. Caching pattern (cache-aside)

Caching is the primary reason to reach for Redis on Vercel. The cache-aside (lazy) pattern is: try the cache → on a miss compute the value, write it back with a TTL, then return. The TTL ({ ex: seconds }) caps how stale data can get and lets entries expire on their own — never cache without one.

flowchart TD
  START["request needs data"] --> GET["redis.get key"]
  GET --> HIT{"cache hit"}
  HIT -->|"yes"| RET["return cached value"]
  HIT -->|"no · miss"| COMPUTE["compute value<br/>db query · API call"]
  COMPUTE --> SET["redis.set key value<br/>ex = TTL seconds"]
  SET --> RET

A reusable helper — pass a key, a TTL in seconds, and a function that produces the value on a miss:

import { redis } from "@/lib/redis";

/**
 * Cache-aside: return the cached value, or compute it, store it with a TTL, and return it.
 * Values are JSON-serialised by the SDK, so T can be any JSON-safe shape.
 */
export async function cached<T>(
  key: string,
  ttlSeconds: number,
  compute: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get<T>(key);
  if (hit !== null && hit !== undefined) {
    return hit; // cache hit
  }

  const value = await compute(); // miss — do the slow work once
  await redis.set(key, value, { ex: ttlSeconds }); // store with TTL (seconds)
  return value;
}
// Usage: cache an exchange rate for 5 minutes.
const rate = await cached("fx:usd-kes", 300, async () => {
  const res = await fetch("https://api.example.com/fx/usd-kes");
  return (await res.json()) as { rate: number };
});

Gotcha — cache stampede. When a hot key expires, many concurrent requests all miss at once and hammer the origin (DB / upstream API) in parallel before the first one repopulates the cache. For hot keys, mitigate with a short lock (set with nx as a mutex), a stale-while-revalidate window, or jittered TTLs so keys don't all expire on the same tick.

5. Rate limiting (protect API routes)

import { Ratelimit } from "@upstash/ratelimit";
import { redis } from "@/lib/redis";

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "10 s"),
});

const { success } = await ratelimit.limit(userId);
if (!success) return new Response("Too many requests", { status: 429 });

6. QStash — publish a background job

The producer publishes once and QStash handles delivery — here is the full job lifecycle:

sequenceDiagram
  participant App as "Edge function"
  participant Q as "QStash"
  participant Route as "Receiver route"
  App->>Q: "publishJSON - url + body"
  Q-->>App: "messageId"
  Q->>Route: "POST signed message"
  Route->>Route: "verify signature"
  Route->>Route: "do the slow work"
  Route-->>Q: "200 ok"
  Note over Q,Route: "On failure QStash retries automatically"
"use server";
import { Client } from "@upstash/qstash";

const qstash = new Client({ token: process.env.QSTASH_TOKEN! });

export async function startBackgroundJob() {
  const { messageId } = await qstash.publishJSON({
    url: "https://<your-app>.vercel.app/api/long-task", // must be a public HTTPS URL
    body: { hello: "world" },
    // schedule instead of run-now:  cron: "0 9 * * *"
  });
  return messageId;
}

7. QStash — receive + verify the message

Always verify the signature so only QStash can trigger the endpoint:

// app/api/long-task/route.ts
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";

export const POST = verifySignatureAppRouter(async (req: Request) => {
  const body = await req.json();
  // ... do the slow work here
  return new Response("ok");
});

codeAmani notes

Official docs:

Vercel AI SDK + AI Gateway Integration Guide

Technology: vercel-ai-sdk · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/vercel-ai-sdk

Insight:

This is the unification layer: one generateText/streamText interface over every provider, with the AI Gateway adding a single key, automatic fallback chains, and cost/latency dashboards. Passing model as a string (provider/model) auto-routes through the Gateway — turning the hand-rolled routing in AI_WORKFLOWS.md into config instead of code.

██╗   ██╗███████╗██████╗  ██████╗███████╗██╗          █████╗ ██╗    ███████╗██████╗ ██╗  ██╗
██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝██║         ██╔══██╗██║    ██╔════╝██╔══██╗██║ ██╔╝
██║   ██║█████╗  ██████╔╝██║     █████╗  ██║         ███████║██║    ███████╗██║  ██║█████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██║     ██╔══╝  ██║         ██╔══██║██║    ╚════██║██║  ██║██╔═██╗
 ╚████╔╝ ███████╗██║  ██║╚██████╗███████╗███████╗    ██║  ██║██║    ███████║██████╔╝██║  ██╗
  ╚═══╝  ╚══════╝╚═╝  ╚═╝ ╚═════╝╚══════╝╚══════╝    ╚═╝  ╚═╝╚═╝    ╚══════╝╚═════╝ ╚═╝  ╚═╝

Vercel AI SDK + AI Gateway Integration Guide

Focus: One TypeScript interface (generateText / streamText / generateObject) over every provider codeAmani uses — Anthropic, OpenAI, Gemini, DeepSeek, HuggingFace, ElevenLabs — with AI Gateway adding a single API key, automatic fallback chains, and built-in cost/latency observability. This replaces the hand-rolled routing in AI_WORKFLOWS.md.

Overview

Here is the big picture — one interface, many providers, all routed through a single Gateway:

flowchart LR
  A["Your app code"] --> B["generateText / streamText / generateObject"]
  B --> C["model as string<br/>provider/model"]
  C --> D["AI Gateway<br/>one AI_GATEWAY_API_KEY"]
  D --> E["Anthropic"]
  D --> F["OpenAI"]
  D --> G["Google · DeepSeek · others"]
  E --> H["Streamed tokens + usage"]
  F --> H
  G --> H
  H --> A

Two complementary pieces:

Net effect for codeAmani: the AI routing policy (Claude primary, OpenAI for structured output, HF/others as fallbacks) becomes config, not app code.

Official Documentation

Resource URL
AI SDK intro https://ai-sdk.dev/docs/introduction
Getting started (Node) https://ai-sdk.dev/docs/getting-started/nodejs
Providers list https://ai-sdk.dev/providers/ai-sdk-providers
Tool calling https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling
AI Gateway docs https://vercel.com/docs/ai-gateway
Gateway auth (AI_GATEWAY_API_KEY) https://vercel.com/docs/ai-gateway/authentication
Gateway provider options (order/only/sort) https://vercel.com/docs/ai-gateway/models-and-providers/provider-options
Gateway model fallbacks (gateway.models) https://vercel.com/docs/ai-gateway/models-and-providers/model-fallbacks

1. Install

npm install ai                       # core — v7 (ai@7.x)
# Optional explicit providers (only if NOT using the string/Gateway form):
npm install @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/google @ai-sdk/deepseek
# React chat UI (separate package):
npm install @ai-sdk/react

Version drift: the core ai package is on v7 (7.x) but the provider and React packages track their own lower major lines — @ai-sdk/react, @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google are all on 4.x, @ai-sdk/deepseek on 3.x. That mismatch is expected; always take the latest tag of each rather than trying to match version numbers to ai. The v4→v5→v7 shape changes are breaking — see the gotchas below and re-verify snippets against the docs before copying.

2. Credentials

# Gateway path (recommended) — one key for all providers:
AI_GATEWAY_API_KEY=...
# OR direct-provider path — one key each:
ANTHROPIC_API_KEY=...
OPENAI_API_KEY=...
GOOGLE_GENERATIVE_AI_API_KEY=...

On Vercel, the Gateway can also authenticate via the deployment's OIDC token (VERCEL_OIDC_TOKEN) with no key at all. Keep every key server-side.

3. Generate text (Gateway via model string)

import { generateText } from "ai";

const { text } = await generateText({
  model: "anthropic/claude-sonnet-5", // string -> routed through AI Gateway
  prompt: "Explain M-Pesa STK Push in one sentence.",
});

Switching providers is a one-line change — "openai/gpt-5", "google/gemini-2.5-flash", "deepseek/deepseek-chat". The surrounding code never changes.

v7 naming (breaking vs. v4/v5): the system prompt field is now instructions: (was system:), the per-response cap is maxOutputTokens: (was maxTokens:), tool schemas use inputSchema: (was parameters:), and multi-step tool loops use stopWhen: isStepCount(n) (import isStepCount from ai; was maxSteps: n). prompt and messages are unchanged.

4. Stream + structured output

import { streamText, generateObject } from "ai";
import { z } from "zod";

const result = streamText({ model: "google/gemini-2.5-flash", prompt });
for await (const chunk of result.textStream) process.stdout.write(chunk);

const { object } = await generateObject({
  model: "openai/gpt-5",
  schema: z.object({ amount: z.number(), phone: z.string() }),
  prompt: "Extract the payment amount and phone from: 'Send 500 to 0712345678'",
});

4a. useChat — streaming chat UI (AI SDK v7)

For a React chat UI, the useChat hook (from @ai-sdk/react) handles message state, streaming, and input wiring; the matching route handler runs streamText on the server and hands the typed stream back with createUIMessageStreamResponse({ stream: toUIMessageStream(...) }). This targets AI SDK v7 — the message shape is UIMessage (rendered via message.parts, not a flat content string), and the client posts through a DefaultChatTransport. Keep the API route server-side so your AI_GATEWAY_API_KEY never reaches the browser.

sequenceDiagram
  participant U as "Browser · useChat"
  participant R as "Route · api/chat"
  participant G as "AI Gateway"
  U->>R: "POST UIMessage list"
  R->>G: "streamText · convertToModelMessages"
  G-->>R: "typed part stream"
  R-->>U: "createUIMessageStreamResponse"
// app/(public)/chat/page.tsx
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";

export default function Chat() {
  const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });
  const [input, setInput] = useState("");

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}: </strong>
          {m.parts.map((part, i) =>
            part.type === "text" ? <span key={i}>{part.text}</span> : null,
          )}
        </div>
      ))}

      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim()) return;
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input
          value={input}
          placeholder="Uliza chochote..."
          onChange={(e) => setInput(e.target.value)}
        />
      </form>
    </div>
  );
}
// app/api/chat/route.ts
import {
  streamText,
  convertToModelMessages,
  createUIMessageStreamResponse,
  toUIMessageStream,
  type UIMessage,
} from "ai";

export const maxDuration = 30; // allow streaming responses up to 30s

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: "anthropic/claude-sonnet-5", // string -> AI Gateway
    instructions: "You are a helpful assistant for Kenyan SMEs.",
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

Gotcha: the v7 route handler wraps the result's typed part stream with toUIMessageStream({ stream: result.stream }) and returns it via createUIMessageStreamResponse(...). (The older result.toUIMessageStreamResponse() still works as a shorthand — Vercel's Gateway docs use it — but the two-call form above is the current canonical shape and is what the AI SDK docs show. Both replace the v4 toDataStreamResponse().) You must pass await convertToModelMessages(messages) to streamText — handing the raw UIMessage[] (with parts) straight to the model throws. The client reads message.parts, so there is no message.content string to render. Note the system prompt is instructions: in v7, not system:.

5. Fallback chains (the replacement for hand-rolled routing)

You can let the Gateway handle failover automatically — here is the order it walks:

flowchart TD
  A["Request"] --> B["Primary<br/>anthropic/claude-sonnet-5"]
  B --> Q1{"Primary OK?"}
  Q1 -->|"yes"| Z["Return text"]
  Q1 -->|"fails"| C["Fallback 1<br/>openai/gpt-5"]
  C --> Q2{"Fallback 1 OK?"}
  Q2 -->|"yes"| Z
  Q2 -->|"fails"| D["Fallback 2<br/>google/gemini-2.5-flash"]
  D --> Z
const { text } = await generateText({
  model: "anthropic/claude-sonnet-5", // primary
  prompt,
  providerOptions: {
    gateway: {
      models: ["openai/gpt-5", "google/gemini-2.5-flash"], // tried in order if primary fails
      // order: ["anthropic", "vertex"], // or pin provider routing order
    },
  },
});

The gateway.models fallback array now has its own docs page — Model Fallbacks (linked above). order / only / sort (provider routing) live on the Provider Options page. Both are configured under providerOptions.gateway.

Cost, latency, and tokens-per-model are visible in the AI Gateway dashboard — no custom metrics code needed.

codeAmani notes

Official docs:

Vercel Integration Guide

Technology: vercel · Category: hosting · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/vercel

Insight:

Vercel is the primary host: a git push to master auto-deploys (this dashboard runs that way). Fluid Compute is now the default runtime — full Node.js (24 LTS), Active-CPU pricing, and functions that can even hold WebSockets and 100 MB request bodies — so stay on it and treat the Edge runtime as legacy (Vercel no longer recommends it). Per-PR preview deploys are the safe way to test before production.

██╗   ██╗███████╗██████╗  ██████╗███████╗██╗
██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝██║
██║   ██║█████╗  ██████╔╝██║     █████╗  ██║
╚██╗ ██╔╝██╔══╝  ██╔══██╗██║     ██╔══╝  ██║
 ╚████╔╝ ███████╗██║  ██║╚██████╗███████╗███████╗
  ╚═══╝  ╚══════╝╚═╝  ╚═╝ ╚═════╝╚══════╝╚══════╝

Vercel Integration Guide

Focus: Deploying, inspecting, and automating Vercel projects from inside Claude Code using the official Vercel MCP server and Vercel CLI.

Overview

Vercel is a full compute platform — not just a frontend/static host. It runs full backend frameworks (Express, FastAPI, NestJS, Hono, …) natively with zero config, and its default runtime is Fluid Compute (regular Node.js with instance reuse), which replaced the old push toward the Edge runtime. Its official MCP server gives Claude Code direct access to deployments, logs, runtime errors, projects, Web Analytics, and domains — no browser required. Combined with the vercel CLI and GitHub Actions, you can build fully automated deploy, preview, and rollback pipelines driven by Claude Code.

Here is the core flow at a glance — a single git push fans out into builds, previews, and a production deploy on the edge:

flowchart LR
  A["git push"] --> B{"Branch?"}
  B -->|"master"| C["Build"]
  B -->|"feature branch / PR"| D["Build"]
  C --> E["Production deploy"]
  D --> F["Preview deploy<br/>per-PR URL"]
  E --> G["Edge CDN<br/>global users"]
  F --> H["Test before<br/>promoting to prod"]

Official Documentation

Resource URL
Vercel Docs https://vercel.com/docs
Vercel MCP Server https://vercel.com/docs/agent-resources/vercel-mcp
Vercel CLI Reference https://vercel.com/docs/cli
REST API https://vercel.com/docs/rest-api
Next.js Docs https://nextjs.org/docs

MCP Server Setup

Vercel hosts an official MCP server at https://mcp.vercel.com using OAuth authentication (it implements the current MCP Authorization + Streamable HTTP specs).

# Add Vercel MCP to Claude Code (authenticates via OAuth browser flow)
claude mcp add --transport http vercel https://mcp.vercel.com

# then, inside `claude`, authorize the connection:
/mcp

Claude Code will open a browser to complete OAuth. Once done, the token is cached automatically. To wire the same server into every installed agent at once, Vercel also ships a one-shot installer: npx -y add-mcp https://mcp.vercel.com -g.

.mcp.json Configuration (Token-based)

{
  "mcpServers": {
    "vercel": {
      "type": "http",
      "url": "https://mcp.vercel.com",
      "headers": {
        "Authorization": "Bearer ${VERCEL_TOKEN}"
      }
    }
  }
}

Available MCP Tools

The tool set has grown well beyond deploy inspection. Core tools you will reach for:

Tool Description
search_vercel_documentation Search Vercel docs in natural language
list_teams List your Vercel teams
list_projects / get_project List projects; get framework, domains, latest deploy
list_deployments / get_deployment List deployments; get status and URLs
get_deployment_build_logs Fetch build logs (errorsOnly to isolate failures)
get_runtime_logs Fetch function runtime logs with filters/full-text search
get_runtime_errors Grouped production error clusters — start here before get_runtime_logs
deploy_to_vercel Deploy a supplied file tree to preview or production
get_web_analytics Query visitors, page views, and custom events
check_domain_availability_and_price / buy_domain Check and purchase domains
use_vercel_cli Run Vercel CLI commands through the server

Additional categories exist: Agent Runs observability (list_agent_runs, get_agent_run, get_agent_run_trace — for eve agents), Purchase (buy_pro, buy_credits, buy_addon), Access (web_fetch_vercel_url), Design import, and Toolbar threads. See the tools reference for the full, current list.


CLI Integration

Installation

npm install -g vercel

Authentication

vercel login
# Or use a token:
vercel login --token $VERCEL_TOKEN

Key Commands

# Deploy current directory
vercel deploy

# Deploy to production
vercel --prod

# List deployments
vercel ls

# Inspect a deployment
vercel inspect <deployment-url>

# View logs
vercel logs <deployment-url>

# Manage environment variables
vercel env add MY_VAR production
vercel env ls production
vercel env rm MY_VAR production

# Rollback to previous deployment
vercel rollback

# Pull env vars to local .env
vercel env pull .env.local

# Link project to local directory
vercel link

# Open project dashboard in browser
vercel open

Environment Variables

# Vercel token (from vercel.com/account/tokens)
VERCEL_TOKEN=...

# Project and team (from project settings or `vercel link`)
VERCEL_ORG_ID=team_...
VERCEL_PROJECT_ID=prj_...

# Used inside deployed functions
NEXT_PUBLIC_API_URL=https://api.example.com
DATABASE_URL=postgresql://...

Sync local .env.local with Vercel:

vercel env pull .env.local

Compute model — Fluid Compute (default)

Since April 2025 Fluid Compute is the default runtime for new projects, and it changes several long-held assumptions. It reuses a single function instance across concurrent requests (fewer cold starts), keeps the full Node.js API surface, and bills on Active CPU — you pay for CPU time while your code executes, plus provisioned memory and invocations, not wall-clock GB-seconds. Enable it explicitly with "fluid": true in the config file if a project predates the default.

What this means in practice (correct these if you learned Vercel a year ago):

codeAmani angle: because a Fluid Node.js function can now hold persistent connections and stream, you no longer need to route realtime/long-lived work off-platform by reflex. It also unlocks AI Gateway (one API across providers with fallbacks), Queues (durable event streaming), and Sandbox (isolated code execution) for AI features.

Project configuration lives at the repo root and controls rewrites, redirects, response headers, per-function compute (region, runtime, maxDuration), Fluid Compute, and scheduled crons. Settings here are committed to git and apply on every deploy, so they are the durable counterpart to anything you can also click in the dashboard.

vercel.ts is now the recommended format. It replaces vercel.json with full TypeScript — typed config, helper functions, dynamic logic, and access to deployment-time env vars. Install @vercel/config and export a typed config:

// vercel.ts
import { routes, deploymentEnv, type VercelConfig } from '@vercel/config/v1';

export const config: VercelConfig = {
  buildCommand: 'npm run build',
  framework: 'nextjs',
  rewrites: [
    // front an external API under your own domain
    routes.rewrite('/api/proxy/(.*)', 'https://api.example.com/$1', {
      requestHeaders: { authorization: `Bearer ${deploymentEnv('API_TOKEN')}` },
    }),
  ],
  redirects: [
    routes.redirect('/old-pricing', '/pricing', { permanent: true }),
  ],
  headers: [
    routes.header('/(.*)', [
      { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
    ]),
    routes.cacheControl('/static/(.*)', { public: true, maxAge: '1 week', immutable: true }),
  ],
  crons: [
    { path: '/api/reconcile-mpesa', schedule: '0 * * * *' },
  ],
};

Install: npm i @vercel/config. The routes.* helpers (rewrite, redirect, header, cacheControl) build the same route objects vercel.json uses, and deploymentEnv('NAME') injects an env var at deploy time. Import from @vercel/config/v1 to pin the schema.

vercel.json (still fully supported)

vercel.json remains valid and is what most existing projects (including this dashboard) use. Start the file with the $schema line for editor autocomplete and validation.

How a request and a scheduled job flow through it:

flowchart TD
  A["Incoming request"] --> B{"Match in vercel.json?"}
  B -->|"redirects"| C["3xx to new URL"]
  B -->|"rewrites"| D["Proxy to destination<br/>URL unchanged"]
  B -->|"headers"| E["Attach response headers"]
  D --> F["Function runs<br/>region · CPU type · maxDuration"]
  G["Vercel cron scheduler"] -->|"crons path"| F
  F --> H["Response to user"]
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "redirects": [
    { "source": "/old-pricing", "destination": "/pricing", "permanent": true }
  ],
  "rewrites": [
    { "source": "/api/proxy/:path*", "destination": "https://api.example.com/:path*" }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
        { "key": "X-Content-Type-Options", "value": "nosniff" }
      ]
    }
  ],
  "functions": {
    "app/api/mpesa/stk/route.ts": {
      "maxDuration": 30
    }
  },
  "crons": [
    { "path": "/api/reconcile-mpesa", "schedule": "0 * * * *" }
  ]
}

Field notes (verified against the current schema):

Gotcha — secure your cron endpoints. Cron paths are publicly reachable URLs; anyone who guesses /api/reconcile-mpesa can trigger your job. Vercel sends an Authorization: Bearer <CRON_SECRET> header on scheduled invocations — set a CRON_SECRET env var and reject any request whose header does not match:

// app/api/reconcile-mpesa/route.ts
export function GET(request: Request) {
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  // ... reconciliation logic
  return Response.json({ ok: true });
}

Second gotcha: under Fluid Compute (the default) there is no memory (MB) field — pick a CPU type (Standard/Performance) in the project dashboard instead. maxDuration, regions, and fluid still work in the file (and in vercel.ts).


Automation Workflows

You are in great shape to automate the full loop — here is how Claude Code drives a deploy with the CLI and then inspects the result through the MCP server:

sequenceDiagram
  participant U as "You"
  participant CC as "Claude Code"
  participant CLI as "Vercel CLI"
  participant MCP as "Vercel MCP"
  U->>CC: "Run /project-deploy"
  CC->>CLI: "vercel --prod --yes"
  CLI-->>CC: "Deployment URL"
  CC->>MCP: "list_deployments"
  MCP-->>CC: "Status and build logs"
  CC-->>U: "URL, status, warnings"

Claude Code Hook: Post-Deploy Notification

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_TOOL_OUTPUT\" | grep -q 'vercel --prod'; then node scripts/notify-deploy.js; fi"
          }
        ]
      }
    ]
  }
}

Slash Command: Deploy and Inspect

.claude/commands/deploy.md:

Deploy the current project to Vercel production and report the deployment URL and status.

Use Bash to run:
```bash
vercel --prod --yes 2>&1 | tail -5

Then use the Vercel MCP tool list_deployments to get the latest deployment's URL and build status. Report back with the deployment URL, status, and any build warnings.


Usage: `/project:deploy`

### CI/CD: GitHub Actions with Vercel

```yaml
# .github/workflows/deploy.yml
name: Deploy to Vercel
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Vercel CLI
        run: npm install -g vercel
      - name: Pull Vercel environment
        run: vercel env pull .env.production --token=${{ secrets.VERCEL_TOKEN }}
      - name: Build
        run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
      - name: Deploy
        id: deploy
        run: |
          url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
          echo "url=$url" >> $GITHUB_OUTPUT
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `Deployed to: ${{ steps.deploy.outputs.url }}`
            })

Preview Deployments per Branch

# Deploy a preview for the current branch
vercel deploy --env BRANCH=$(git branch --show-current)

Common Use Cases

Use Case Approach
Inspect failing build MCP get_deployment_build_logs (errorsOnly)
Triage production errors MCP get_runtime_errors first, then get_runtime_logs to drill in
Rollback bad release vercel rollback or MCP
Manage env vars vercel env add/ls/rm
Domain assignment MCP check_domain_availability_and_price / buy_domain
Preview links in PRs GitHub Actions + vercel deploy
Realtime / streaming / WebSockets Fluid Node.js function (no separate socket server)
Multi-provider AI with fallbacks Vercel AI Gateway
Long-running / durable jobs Vercel Workflows (unbounded) or Queues (durable events)

Troubleshooting

Issue Fix
vercel: command not found npm install -g vercel
OAuth timeout Use VERCEL_TOKEN in .mcp.json instead
Build failing Use MCP get_deployment_build_logs to read errors
Env vars missing in prod Run vercel env ls production to verify
VERCEL_PROJECT_ID unknown Run vercel link in project root

Official docs:

Visual Studio Code Integration Guide

Technology: vscode · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/vscode

Insight:

VS Code's real superpower on Windows is not the editor — it's the client/server split. Install VS Code on Windows, type code . inside Ubuntu, and the UI stays on Windows while the language servers, terminal, debugger, and file watchers all run as Linux processes on the Linux filesystem. That is the only setup where a Next.js/TypeScript stack behaves identically on a dev laptop and on Vercel's Linux builders. The trade-off: extensions now live in two places and you have to know which side each one runs on.

 ██╗   ██╗███████╗     ██████╗ ██████╗ ██████╗ ███████╗
 ██║   ██║██╔════╝    ██╔════╝██╔═══██╗██╔══██╗██╔════╝
 ██║   ██║███████╗    ██║     ██║   ██║██║  ██║█████╗
 ╚██╗ ██╔╝╚════██║    ██║     ██║   ██║██║  ██║██╔══╝
  ╚████╔╝ ███████║    ╚██████╗╚██████╔╝██████╔╝███████╗
   ╚═══╝  ╚══════╝     ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝

Visual Studio Code Integration Guide

Focus: A developer's guide to VS Code driving WSL Ubuntu from Windows — the WSL extension, code ., extension placement, workspace/remote settings, the integrated terminal, tasks, debugging, dev containers, and running Claude Code inside it.

Overview

VS Code is a free, cross-platform editor built on Electron with a remote-capable architecture: the workbench (UI, themes, keybindings) runs on your machine, while a VS Code Server process can run somewhere else — inside a WSL distro, over SSH, or in a container. The WSL extension (ms-vscode-remote.remote-wsl) installs that server into your Linux distro and runs "commands and other extensions directly in WSL so you can edit files located in WSL or the mounted Windows filesystem (for example /mnt/c) without worrying about pathing issues, binary compatibility, or other cross-OS challenges."

For codeAmani that matters because the whole stack — Next.js 15, Node, pnpm/npm, Prisma engines, sharp, Playwright browsers, Docker — is built and tested on Linux. Developing on Windows-native Node and deploying to Vercel's Linux builders is the classic source of "works on my machine": case-sensitive imports, node_modules binaries compiled for the wrong platform, CRLF diffs, and path separators. Remote-WSL removes the class entirely.

flowchart LR
  subgraph WIN["Windows 11 — client side"]
    A["VS Code workbench<br/>UI · themes · keybindings"]
    B["code CLI on PATH"]
  end
  subgraph WSL["WSL 2 · Ubuntu — server side"]
    C["VS Code Server<br/>~/.vscode-server"]
    D["Extension host<br/>ESLint · TS server · Tailwind"]
    E["Integrated terminal<br/>bash · node · git · claude"]
    F["Debugger + file watchers<br/>on /home/&lt;user&gt;/projects"]
  end
  B -->|"code . · code --remote wsl+Ubuntu"| A
  A <-->|"RPC over the WSL boundary"| C
  C --> D
  C --> E
  C --> F

Not to be confused with Visual Studio

VS Code (this guide) Visual Studio (see visual-studio/)
What it is Cross-platform editor, ~200 MB Full Windows IDE, multi-GB
Platforms Windows, macOS, Linux, web Windows (and a separate macOS product, retired)
Primary stack JS/TS, Python, Go, Rust, anything .NET, C++, MSBuild
Extension model Node/TypeScript extensions from the VS Code Marketplace VSIX / NuGet, Microsoft.VisualStudio.Extensibility or VSSDK + MEF
Remote dev First-class (WSL, SSH, containers, tunnels) Not the same model
Config JSON (settings.json, launch.json, tasks.json) .sln / .csproj / MSBuild

They share a name and nothing else. This guide is VS Code.

Local Windows vs Remote-WSL

Concern VS Code on Windows only VS Code + WSL extension
Node / package manager Windows build Linux build — same as CI and Vercel
Path & case semantics Case-insensitive, \ separators Case-sensitive, / — matches production
File watching Native Native on ext4; polling needed on WSL 1
node_modules native binaries Windows-compiled Linux-compiled
Terminal PowerShell / Git Bash Real bash in the distro
Docker / dev containers Docker Desktop Docker Desktop WSL 2 backend, or Docker in the distro

Official Documentation

Resource URL
Docs home https://code.visualstudio.com/docs
Developing in WSL https://code.visualstudio.com/docs/remote/wsl
WSL tutorial (start here) https://code.visualstudio.com/docs/remote/wsl-tutorial
Remote development overview https://code.visualstudio.com/docs/remote/remote-overview
Settings (scopes & precedence) https://code.visualstudio.com/docs/configure/settings
Debug configuration (launch.json) https://code.visualstudio.com/docs/debugtest/debugging-configuration
Tasks (tasks.json) https://code.visualstudio.com/docs/debugtest/tasks
Terminal profiles https://code.visualstudio.com/docs/terminal/profiles
Extension Marketplace https://code.visualstudio.com/docs/configure/extensions/extension-marketplace
code CLI reference https://code.visualstudio.com/docs/configure/command-line
Dev Containers https://code.visualstudio.com/docs/devcontainers/containers
WSL extension (Marketplace) https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl
Working across filesystems (Microsoft) https://learn.microsoft.com/en-us/windows/wsl/filesystems

Setup — Windows + WSL Ubuntu in five minutes

Order matters: WSL first, VS Code on the Windows side, extension last.

# 1. Windows side — install WSL 2 + Ubuntu (PowerShell as Administrator, once)
wsl --install -d Ubuntu

# 2. Install VS Code on WINDOWS, not inside the distro.
#    https://code.visualstudio.com/download
#    On the "Select Additional Tasks" screen, CHECK "Add to PATH".

# 3. Install the WSL extension (any shell where `code` is on PATH)
code --install-extension ms-vscode-remote.remote-wsl

# ...or the whole Remote Development pack (WSL + SSH + Dev Containers)
code --install-extension ms-vscode-remote.vscode-remote-extensionpack

# 4. Verify
code --version
code --list-extensions --show-versions

Do not apt install code inside Ubuntu. The WSL extension pushes its own VS Code Server into ~/.vscode-server; a second Linux-native VS Code is redundant and confuses code on the distro PATH.

The daily loop

From an Ubuntu shell, in the project folder:

cd ~/projects/boda-dispatch
code .

First run downloads the server components into WSL (once, ~30s). A WSL: Ubuntu indicator appears in the bottom-left status bar — that is the single reliable signal that you are editing on the Linux side.

Other entry points:

# From Windows PowerShell / CMD — open a WSL path directly
code --remote wsl+Ubuntu /home/<user>/projects/boda-dispatch

# Force folder interpretation for a path containing a dot
code --folder-uri vscode-remote://wsl+Ubuntu/home/<user>/app.v2

# Already inside a WSL window? The same code CLI works there too
code --diff old.ts new.ts
code --goto lib/domain/dispatch.ts:42

From the Command Palette (F1) on the Windows side:

Command Does
WSL: Connect to WSL New window on the default distro
WSL: Connect to WSL using Distro Pick a specific distro
WSL: Reopen Folder in WSL Move the current folder to the Linux side
WSL: Reopen in Windows Move it back

Put the code on the Linux filesystem

Microsoft is unambiguous: "We recommend against working across operating systems with your files… For the fastest performance speed, store your files in the WSL file system if you are working in a Linux command line."

✅  /home/<user>/projects/boda-dispatch      ← ext4, fast, correct case semantics
❌  /mnt/c/Users/<user>/projects/boda-...    ← 9p bridge; npm install and HMR crawl

Editing /mnt/c works and VS Code handles the paths, but npm install, Turbopack HMR, and file watching over the Windows mount are dramatically slower. To browse the Linux files from Windows Explorer, run explorer.exe . from the WSL shell, or type \\wsl$ in the Explorer address bar.


Extensions: two installs, two homes

Once connected, the Extensions view splits into Local - Installed (UI-side: themes, icons, keymaps) and WSL: Ubuntu - Installed (everything that touches code or the filesystem: language servers, linters, formatters, debuggers, test runners). Installing from the Extensions view while connected puts the extension in the right place automatically. Extensions that should be remote but are only installed locally appear dimmed with an Install in WSL: Ubuntu button, and the cloud icon in the Local - Installed title bar offers Install Local Extensions in WSL: {Name} for a bulk move.

Commit the stack's recommendations so a new machine is one click from correct — .vscode/extensions.json:

{
  "recommendations": [
    "ms-vscode-remote.remote-wsl",
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "bradlc.vscode-tailwindcss",
    "Prisma.prisma",
    "vitest.explorer",
    "ms-playwright.playwright",
    "eamodio.gitlens",
    "ms-vscode-remote.remote-containers",
    "anthropic.claude-code"
  ]
}

VS Code surfaces these as Workspace Recommendations; Extensions: Configure Recommended Extensions (Workspace Folder) generates the file. Scripted setup inside the distro:

code --install-extension dbaeumer.vscode-eslint \
     --install-extension esbenp.prettier-vscode \
     --install-extension bradlc.vscode-tailwindcss --force

Rarely, an extension guesses wrong about where to run. Override it explicitly:

// settings.json — "ui" = local/client, "workspace" = remote (WSL)
"remote.extensionKind": {
  "ms-azuretools.vscode-containers": ["ui"]
}

Use this sparingly — the docs warn it can break extensions.


Settings: four scopes, one precedence order

Precedence, lowest → highest: default → user → remote → workspace → workspace folder → language-specific → policy. The remote layer is the one people forget.

Scope Where Use it for
User %APPDATA%\Code\User\settings.json (Windows client) Theme, font, keybindings — travels everywhere
Remote Preferences: Open Remote Settings → the Remote tab Anything true only inside WSL (interpreter paths, watcher tuning)
Workspace .vscode/settings.json — commit this Team formatting, TS SDK, per-repo behavior
Workspace folder Per-folder in a multi-root .code-workspace Monorepo package overrides

A sane committed .vscode/settings.json for the Next.js/TypeScript stack:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },

  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "typescript.preferences.importModuleSpecifier": "non-relative",

  "files.eol": "\n",
  "files.exclude": { "**/.next": true },
  "files.watcherExclude": { "**/node_modules/**": true, "**/.next/**": true },
  "search.exclude": { "**/node_modules": true, "**/.next": true, "**/dist": true },

  "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
  "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
  "tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
}

"files.eol": "\n" is not cosmetic here — the same repo touched from Windows and WSL is the documented cause of "every file is modified" Git noise.


Integrated terminal

Once a folder is open in WSL, any terminal you open (Terminal → New Terminal, Ctrl+`) is already a bash shell in the distro, cwd at the workspace root. No profile configuration needed — that is the whole point.

For local Windows windows, VS Code auto-detects WSL distros as terminal profiles (terminal.integrated.useWslProfiles, on by default). To pin one:

{
  "terminal.integrated.defaultProfile.windows": "Ubuntu (WSL)",
  "terminal.integrated.profiles.windows": {
    "Ubuntu (WSL)": { "path": "C:\\WINDOWS\\System32\\wsl.exe", "args": ["-d", "Ubuntu"] }
  }
}

Gotcha: when the VS Code Server starts in WSL, no shell startup scripts are run — .bashrc/.profile are skipped for the server process. Terminals you open still source them, but tasks and debug sessions inherit the server's environment. If a tool needs env setup before the server boots, put it in ~/.vscode-server/server-env-setup, which is processed before the server starts.

terminal.integrated.automationProfile.<platform> gives tasks and the debugger a lighter shell when your interactive profile has heavy startup (oh-my-zsh, nvm, direnv).


Tasks — tasks.json

Tasks run in WSL when the window is remote, so npm run dev is Linux npm. npm scripts are auto-detected (Tasks: Run Task); write explicit tasks when you need a preLaunchTask, a problem matcher, or ordering.

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "dev",
      "type": "shell",
      "command": "npm run dev",
      "isBackground": true,
      "problemMatcher": {
        "owner": "typescript",
        "pattern": { "regexp": "^$" },
        "background": {
          "activeOnStart": true,
          "beginsPattern": "starting the development server",
          "endsPattern": "Ready in"
        }
      },
      "presentation": { "reveal": "always", "panel": "dedicated" }
    },
    {
      "label": "typecheck",
      "type": "shell",
      "command": "npx tsc --noEmit",
      "group": { "kind": "build", "isDefault": true },
      "problemMatcher": ["$tsc"]
    },
    {
      "label": "verify",
      "dependsOn": ["typecheck", "lint", "test"],
      "dependsOrder": "sequence",
      "group": "test"
    }
  ]
}

isBackground: true requires a background matcher with beginsPattern/endsPattern — without it a watch task used as preLaunchTask hangs the debug launch forever. Set options.cwd for monorepo packages.


Debugging — launch.json

Every launch config needs type, request (launch | attach), and name. In a WSL window the app starts in WSL and the debugger attaches there; nothing extra to configure. Next.js's own documented configuration:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Next.js: debug server-side",
      "type": "node-terminal",
      "request": "launch",
      "command": "npm run dev -- --inspect"
    },
    {
      "name": "Next.js: debug client-side",
      "type": "chrome",
      "request": "launch",
      "url": "http://localhost:3000"
    },
    {
      "name": "Next.js: debug full stack",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/node_modules/next/dist/bin/next",
      "runtimeArgs": ["--inspect"],
      "skipFiles": ["<node_internals>/**"],
      "serverReadyAction": {
        "action": "debugWithChrome",
        "killOnServerStop": true,
        "pattern": "- Local:.+(https?://.+)",
        "uriFormat": "%s",
        "webRoot": "${workspaceFolder}"
      }
    }
  ]
}

Attributes worth knowing: env / envFile (point at .env.local), cwd (monorepos), console: "integratedTerminal", preLaunchTask / postDebugTask, compounds to start server + client together, and ${workspaceFolder} / ${env:NAME} substitution. serverReadyAction.pattern scans stdout and opens a browser debug session on the captured URL — that is what makes full-stack breakpoints work in one F5.

WSL port forwarding is automatic. A dev server bound in the distro is reachable at http://localhost:3000 from the Windows browser; VS Code's Ports view lists forwarded ports for remote windows.


Dev containers from WSL

With Docker Desktop's WSL 2 backend (Settings → Resources → WSL Integration, enable your distro), open the folder in WSL first, then run Dev Containers: Reopen in Container. If there is no .devcontainer/devcontainer.json, VS Code offers Dev Containers: Add Dev Container Configuration Files.

{
  "name": "codeAmani Next.js",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:22",
  "features": { "ghcr.io/devcontainers/features/github-cli:1": {} },
  "forwardPorts": [3000],
  "postCreateCommand": "npm ci",
  "remoteUser": "node",
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"],
      "settings": { "editor.formatOnSave": true }
    }
  }
}

Rule of thumb: WSL for daily work (fast, zero ceremony, one shared Node/pnpm store), dev containers when the environment itself is the deliverable — a pinned Postgres + app pair, or onboarding where "install these six things" is the bottleneck. customizations.vscode.extensions is the container's answer to .vscode/extensions.json.


Claude Code inside VS Code

Two distinct things, and mixing them up is the usual confusion:

Claude Code VS Code extension Claude Code CLI
Install anthropic.claude-code from the Marketplace Standalone install, inside the distro
Surface Native chat panel with inline diffs, plan review, @-mentions claude in the integrated terminal
Requires VS Code 1.94.0+ Nothing but a shell
PATH Bundles a private CLI copy — does not put claude on PATH Is the thing on your PATH
# In the VS Code integrated terminal (already a WSL bash shell)
claude

codeAmani notes


Troubleshooting

Issue Fix
code . not found in Ubuntu VS Code wasn't installed with Add to PATH on Windows, or the terminal predates the install — restart the shell, or reinstall checking the box
Editing feels slow, HMR lags Project is on /mnt/c. Move it to /home/<user>/… — Microsoft recommends against working across filesystems
Extension missing / greyed out It's installed Local but needs to run remote — click Install in WSL: Ubuntu in the Extensions view
Extension still runs on the wrong side Force it with "remote.extensionKind": { "<publisher.ext>": ["ui" | "workspace"] } — sparingly, it can break extensions
EACCES: permission denied renaming a folder Known WSL 1 issue — set remote.WSL.fileWatcher.polling: true (and raise remote.WSL.fileWatcher.pollingInterval on large repos), or move to WSL 2
Whole repo shows as modified in Git CRLF/LF mismatch — set files.eol: "\n" and core.autocrlf input
Env var visible in bash but not to a task/debug session The server skips shell startup scripts — put it in ~/.vscode-server/server-env-setup
Debug session never starts after a watch task isBackground task missing a background problem matcher (beginsPattern/endsPattern)
Extensions fail on Alpine distros glibc dependencies in native extension code — use Ubuntu/Debian for the dev distro
Git pull/sync hangs on a remote window Passphrase-protected SSH key — clone over HTTPS or push from the terminal
VS Code can't see ANTHROPIC_API_KEY Launch it from the shell with code . so it inherits the environment

Official docs:

Visual Studio Integration Guide

Technology: visual-studio · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/visual-studio

Insight:

Visual Studio has two extensibility worlds. The modern Microsoft.VisualStudio.Extensibility SDK runs your extension out-of-process (async, hot-reload, no VS restart) and is where new editor work should start. The legacy VSSDK + MEF model (IClassifier, adornments, taggers) runs in-process via COM and still owns the deepest editor hooks. Pick the SDK first; drop to VSSDK only for a hook the SDK doesn't yet expose. Extensions ship as NuGet/VSIX, never npm.

██╗   ██╗██╗███████╗██╗   ██╗ █████╗ ██╗         ███████╗████████╗██╗   ██╗██████╗ ██╗ ██████╗
██║   ██║██║██╔════╝██║   ██║██╔══██╗██║         ██╔════╝╚══██╔══╝██║   ██║██╔══██╗██║██╔═══██╗
██║   ██║██║███████╗██║   ██║███████║██║         ███████╗   ██║   ██║   ██║██║  ██║██║██║   ██║
╚██╗ ██╔╝██║╚════██║██║   ██║██╔══██║██║         ╚════██║   ██║   ██║   ██║██║  ██║██║██║   ██║
 ╚████╔╝ ██║███████║╚██████╔╝██║  ██║███████╗    ███████║   ██║   ╚██████╔╝██████╔╝██║╚██████╔╝
  ╚═══╝  ╚═╝╚══════╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝    ╚══════╝   ╚═╝    ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝

Visual Studio Integration Guide

Focus: Extending the Visual Studio IDE's editor and tooling — building VSIX extensions with the modern out-of-process VisualStudio.Extensibility SDK and the classic VSSDK + MEF editor model (classifiers, adornments, taggers), and driving builds/tests from Claude Code via MSBuild and the dotnet/devenv CLIs.

Overview

Visual Studio is Microsoft's full Windows IDE for .NET, C++, web, and cross-platform mobile development. Unlike VS Code (a separate, lighter editor with a JavaScript extension model), Visual Studio extensions are .NET assemblies packaged as a VSIX and published to the Visual Studio Marketplace.

Versions & channels (2026-08). Visual Studio 2026 (version 18.x) is now the current major release. It renames the update channels: Stable replaces the old Current channel and Insiders replaces Preview — you can run both side by side. Visual Studio 2022 (17.x) reached its final feature minor at 17.14, which stays on the Current channel and is supported for the rest of its 10-year lifecycle to January 2032. Both major versions build the same extensions; the VisualStudio.Extensibility SDK is still versioned 17.14.x on NuGet (see the packages table) and targets net8.0, so it installs into VS 2022 17.9+ and VS 2026. Check Help > About or devenv /version before scaffolding.

Here's the big picture to keep you oriented — pick your model, scaffold, then let the build tools package the .vsix:

flowchart TD
    A["Need to extend<br/>Visual Studio"] --> Q1{"Hook exposed by<br/>the new SDK?"}
    Q1 -->|"yes"| B["VisualStudio.Extensibility SDK<br/>out-of-process · async · hot-reload"]
    Q1 -->|"no"| C["VSSDK + MEF<br/>in-process COM · deep editor hooks"]
    B --> D["Scaffold contribution classes"]
    C --> D
    D --> E["dotnet build / msbuild"]
    E --> F["Packaged .vsix"]
    F --> G["VS Marketplace or<br/>VSIXInstaller"]

There are two extensibility models, and knowing which you're in saves hours:

Model Process API style Use it when
VisualStudio.Extensibility SDK (new) Out-of-process Async, [VisualStudioContribution], hot-reload New commands, editor listeners, tool windows, LSP — start here
VSSDK + MEF (classic) In-process (COM) [Export]/[Import], requires VS restart Deep editor hooks: IClassifier, adornments, taggers, IntelliSense not yet in the new SDK

Claude Code's role here is codegen + build orchestration: scaffold the extension classes that match the fetched API, then drive dotnet build / msbuild / dotnet test to compile, package, and validate the .vsix.

Official Documentation

Resource URL
Extensibility overview https://learn.microsoft.com/en-us/visualstudio/extensibility/
VisualStudio.Extensibility (new SDK) https://learn.microsoft.com/en-us/visualstudio/extensibility/visualstudio.extensibility/
Language service & editor extension points https://learn.microsoft.com/en-us/visualstudio/extensibility/language-service-and-editor-extension-points
VSExtensibility SDK (GitHub + samples) https://github.com/microsoft/VSExtensibility
Visual Studio IDE docs https://learn.microsoft.com/en-us/visualstudio/ide/
GitHub Copilot in Visual Studio (get started) https://learn.microsoft.com/en-us/visualstudio/ide/visual-studio-github-copilot-get-started

NuGet Packages (tracked manually — not npm/pypi)

The freshness checker only follows npm and PyPI, so the frontmatter packages list is empty. Track these by hand:

Package Purpose Version at review
Microsoft.VisualStudio.Extensibility.Sdk Core out-of-process SDK 17.14.40608
Microsoft.VisualStudio.Extensibility.Build MSBuild targets that pack the .vsix 17.14.40608
Microsoft.VisualStudio.SDK (classic) Meta-package for VSSDK/MEF editor APIs matches your VS version (e.g. 17.x)
Microsoft.VSSDK.BuildTools (classic, CI) Restores VsSDK.targets so classic VSIX builds without a full VS install 18.9.x (latest on NuGet)

Latest versions: https://www.nuget.org/packages/Microsoft.VisualStudio.Extensibility.Sdk

Note — the SDK still tracks 17.14, not the IDE's 18.x. Even though Visual Studio 2026 ships as version 18.x, the modern VisualStudio.Extensibility SDK's latest stable NuGet release is 17.14.40608 (the Microsoft.VSSDK.BuildTools classic-CI package has moved to 18.9.x). Pin 17.14.40608 for the SDK — it is current, not stale — and re-check the NuGet link before a release.


Prerequisites

Visual Studio 2022 17.9+  (or Visual Studio 2026)  with the "Visual Studio extension development" workload
.NET 8 SDK

The SDK's docs still label it VisualStudio.Extensibility (Preview) and the API surface keeps a small set of experimental members — pin the SDK version and expect occasional breaking changes between minors. It remains Microsoft's "start here" model for new extensions.

Project file

The SDK targets net8.0-windows and pulls two NuGet packages. The .Build package wires the VSIX packaging into dotnet build automatically — no source.extension.vsixmanifest hand-editing:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0-windows8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>12</LangVersion>
    <NeutralLanguage>en-US</NeutralLanguage>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.VisualStudio.Extensibility.Sdk"   Version="17.14.40608" PrivateAssets="all" />
    <PackageReference Include="Microsoft.VisualStudio.Extensibility.Build" Version="17.14.40608" PrivateAssets="all" />
  </ItemGroup>
</Project>

Extension entry point

Every extension has one class deriving from Extension, decorated with [VisualStudioContribution]. This is your manifest-in-code:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.Extensibility;

[VisualStudioContribution]
public class InsertGuidExtension : Extension
{
    public override ExtensionConfiguration ExtensionConfiguration => new()
    {
        Metadata = new(
            id: "InsertGuid.c5481000-68da-416d-b337-32122a638980",
            version: this.ExtensionAssemblyVersion,
            publisherName: "codeAmani",
            displayName: "Insert Guid Sample Extension",
            description: "Inserts a GUID at the caret in the active document."),
    };

    protected override void InitializeServices(IServiceCollection serviceCollection)
    {
        base.InitializeServices(serviceCollection);
        // Register your own services for DI here.
    }
}

A command

Commands are Command subclasses, also marked [VisualStudioContribution]. They run async against the out-of-process Extensibility object:

[VisualStudioContribution]
public class InsertGuidCommand : Command
{
    public override CommandConfiguration CommandConfiguration => new("%InsertGuid.DisplayName%")
    {
        Placements = [CommandPlacement.KnownPlacements.ExtensionsMenu],
        Icon = new(ImageMoniker.KnownValues.Extension, IconSettings.IconAndText),
    };

    public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken ct)
    {
        var textView = await context.GetActiveTextViewAsync(ct);
        if (textView is null) return;

        await this.Extensibility.Editor().EditAsync(batch =>
        {
            var doc = textView.Document.AsEditable(batch);
            doc.Replace(textView.Selection.Extent, Guid.NewGuid().ToString());
        }, ct);
    }
}

Build & run

# From the extension project dir — the .Build package produces the .vsix
dotnet build -c Release

# F5 in Visual Studio launches the VS Experimental Instance with the extension
# hot-loaded (no restart). From CLI, install the packaged VSIX:
"%VsInstallDir%\Common7\IDE\VSIXInstaller.exe" bin\Release\MyExtension.vsix

Setup — Classic VSSDK + MEF (deep editor hooks)

When you need an editor hook the new SDK doesn't expose yet — syntax classification, adornments, taggers — use the in-process MEF model. Create a VSIX Project (C# › Extensibility), then add an Editor Classifier item template.

MEF is the wiring: you [Export] a provider and Visual Studio [Import]s it. The editor discovers your component by the exported interface + ContentType.

Middle path — a VSSDK-compatible SDK extension. You no longer have to choose one world wholesale. The VisualStudio.Extensibility Extension with VSSDK Compatibility project template lets a modern SDK extension host classic VSSDK/MEF parts in-process: set <VssdkCompatibleExtension>true</VssdkCompatibleExtension>, mark the Extension with RequiresInProcessHosting = true, and keep the source.extension.vsixmanifest with ExtensionType = VSSDK+VisualStudio.Extensibility. For VS 2022 this variant targets .NET Framework 4.7.2 (not net8.0), because in-process code runs inside devenv.exe. Reach for it when you want the SDK's authoring model but still need a deep MEF hook in the same VSIX. See Using the SDK and VSSDK together.

A classifier (colors text)

[Export(typeof(IClassifierProvider))]
[ContentType("text")]
internal class EditorClassifierProvider : IClassifierProvider
{
    [Import] internal IClassificationTypeRegistryService ClassificationRegistry { get; set; }

    public IClassifier GetClassifier(ITextBuffer buffer) =>
        buffer.Properties.GetOrCreateSingletonProperty(
            () => new EditorClassifier(ClassificationRegistry));
}

internal class EditorClassifier : IClassifier
{
    private readonly IClassificationType _type;
    internal EditorClassifier(IClassificationTypeRegistryService registry) =>
        _type = registry.GetClassificationType("EditorClassifier");

    public IList<ClassificationSpan> GetClassificationSpans(SnapshotSpan span) =>
        new List<ClassificationSpan>
        {
            new(new SnapshotSpan(span.Snapshot, span.Span), _type),
        };

    public event EventHandler<ClassificationChangedEventArgs> ClassificationChanged;
}

The format definition (how the classification looks)

[Export(typeof(EditorFormatDefinition))]
[ClassificationType(ClassificationTypeNames = "EditorClassifier")]
[Name("EditorClassifier")]
[UserVisible(true)]
internal sealed class EditorClassifierFormat : ClassificationFormatDefinition
{
    public EditorClassifierFormat()
    {
        DisplayName = "EditorClassifier";
        BackgroundColor = Colors.BlueViolet;
        ForegroundColor = Colors.White;
    }
}

Editor extension points at a glance

Hook Export Reach for it when
Classifier IClassifierProvider Color/categorize spans of text
Tagger ITaggerProvider Attach typed tags (errors, outlining, highlights) to spans
Adornment AdornmentLayerDefinition + IWpfTextViewCreationListener Draw WPF visuals over/under text
Completion IAsyncCompletionSourceProvider Custom IntelliSense
Margin IWpfTextViewMarginProvider Add a gutter/margin UI strip

MEF components are in-process and lazy — Visual Studio only constructs them when a matching ContentType view opens. Keep constructors cheap; do real work on first use.


Driving Visual Studio from Claude Code

Claude Code runs on the CLI, so orchestrate the build tools, not the GUI. Always pass arguments as arrays (execFileSync) — never interpolate paths into a shell string.

You're the codegen-plus-orchestration layer here — here's how a run flows end to end:

sequenceDiagram
    participant CC as "Claude Code"
    participant FS as "Project files"
    participant BT as "dotnet / msbuild"
    participant VS as "Visual Studio"
    CC->>FS: "Scaffold extension classes"
    CC->>BT: "execFileSync with array args"
    BT->>BT: "Compile and pack .vsix"
    BT-->>CC: "Build result · .vsix path"
    CC->>BT: "dotnet test"
    BT-->>CC: "results.trx"
    CC->>VS: "Install or F5 Experimental Instance"
# Build a solution (prefer the dotnet CLI for SDK-style projects)
dotnet build MyExtension.sln -c Release

# MSBuild for classic VSSDK projects that aren't SDK-style
msbuild MyExtension.sln /p:Configuration=Release /p:DeployExtension=false

# Run tests
dotnet test --logger "trx;LogFileName=results.trx"

# Locate the active VS install (avoids hard-coded paths)
vswhere -latest -property installationPath
// scripts/build-vsix.js — safe argument passing
import { execFileSync } from "node:child_process";

execFileSync("dotnet", ["build", "MyExtension.sln", "-c", "Release"], {
  stdio: "inherit",
});

CI — build the VSIX

The tables above point to "CI build of the .vsix" but never show the workflow. Here it is. Two facts shape it:

The runner must locate msbuild first. The canonical action is microsoft/setup-msbuild, which runs vswhere and prepends the discovered MSBuild to PATH.

Pin the runner image — windows-latest moved to VS 2026. As of 2026 the windows-latest label maps to Windows Server 2025 with Visual Studio 2026 (18.x); Visual Studio 2022 is no longer on that image. So vs-version: '17.0' on windows-latest no longer resolves. Pick one deliberately: pin runs-on: windows-2022 to keep the VS 2022 (17.x) toolset (shown below — matches the 17.14 SDK line), or stay on windows-latest and bump the pin to vs-version: '18.0' for the VS 2026 toolset. Don't leave a 17.0 pin on windows-latest.

flowchart TD
    A["push or PR"] --> B["windows-2022 runner<br/>(pinned — latest now ships VS 2026)"]
    B --> C["actions/checkout"]
    C --> D["microsoft/setup-msbuild<br/>adds msbuild to PATH"]
    D --> E["nuget/msbuild restore"]
    E --> F["msbuild · Release<br/>DeployExtension false"]
    F --> G["actions/upload-artifact<br/>the .vsix"]

Workflow — .github/workflows/build-vsix.yml

name: Build VSIX

on:
  push:
    branches: [master]
  pull_request:

jobs:
  build:
    # Pinned: `windows-latest` now = Windows Server 2025 + Visual Studio 2026 (18.x).
    # `windows-2022` keeps the VS 2022 (17.x) toolset that matches `vs-version: '17.0'`.
    runs-on: windows-2022

    steps:
      - uses: actions/checkout@v4

      # Discovers MSBuild via vswhere and adds it to PATH.
      # vs-version pins the toolset (17.0 = VS 2022; use 18.0 on a VS 2026 runner).
      - name: Add MSBuild to PATH
        uses: microsoft/setup-msbuild@v3
        with:
          vs-version: '17.0'

      # Restore NuGet packages — msbuild -t:Restore avoids a separate nuget.exe.
      - name: Restore
        run: msbuild MyExtension.sln -t:Restore -p:Configuration=Release

      # Build and pack. DeployExtension=false: no local VS to install into.
      - name: Build VSIX
        run: >-
          msbuild MyExtension.sln
          -p:Configuration=Release
          -p:DeployExtension=false
          -m

      - name: Upload VSIX
        uses: actions/upload-artifact@v4
        with:
          name: MyExtension-vsix
          path: '**/bin/Release/**/*.vsix'
          if-no-files-found: error

Gotcha — the VS extension build tooling may be missing. The GitHub-hosted Windows images ship a full Visual Studio install (VS 2022 on windows-2022, VS 2026 on windows-latest/Server 2025) plus the .NET workloads, but a classic VSSDK build also needs the Visual Studio extension development workload (the Microsoft.VsSDK.targets that pack the .vsix). The full VS install usually includes it, but if you hit error MSB4019: The imported project "...Microsoft.VsSDK.targets" was not found, the SDK targets aren't on the runner. Fixes, cheapest first: reference the Microsoft.VSSDK.BuildTools NuGet package so the targets restore with the project (preferred — keeps the build self-contained); or, for a container/self-hosted runner, add the component via the VS Installer (--add Microsoft.VisualStudio.Workload.VisualStudioExtension). The modern VisualStudio.Extensibility SDK sidesteps this entirely — its .Build package brings the packaging targets in as a normal PackageReference.


GitHub Copilot in Visual Studio

Copilot is now a first-class part of the IDE, not an add-on. In Visual Studio 2026 it is built in; in Visual Studio 2022 it ships as the GitHub Copilot optional component in the .NET desktop / other workloads, and agent mode requires 17.14+. Sign in once under Tools > Options > GitHub > Accounts with a GitHub account that has Copilot access (Copilot is a separate GitHub subscription, paid or free).

Surface What it does
Completions + next edit suggestions Inline gray-text completions as you type, plus predicted edits to existing code. IntelliSense still takes Tab by default.
Ask (Copilot Chat) Q&A and code examples with no edits applied unless you choose Apply.
Plan agent Read-only exploration that drafts a reviewable implementation plan (saved as markdown under .copilot/plans/) before any edits — hand it off to agent mode to execute.
Agent mode Multi-step edits across solution files, iterating on build errors and running tools. The evolution of Copilot Edits. Required to use MCP servers.
MCP servers Agent mode can call Model Context Protocol tools via the tools icon — configure servers and pick which tools Copilot may use.

Model picker — including Claude

Copilot Chat has a model picker at the bottom of the chat window. With 17.14 the default model is GPT-4.1 (previously GPT-4o), but the picker exposes an expanded set — Claude Sonnet 4, Claude Opus 4, GPT-5 / GPT-5 mini, Claude Sonnet 3.5, Claude 3.7 (thinking / non-thinking), o3-mini, and Gemini 2.x. Model availability depends on your Copilot plan; for Business/Enterprise an admin enables the models.

Bring your own model (BYOM): in the model picker you can add an API key from Anthropic, OpenAI, or Google and use your own model — but only in the Copilot Chat experience (not completions), and not for Copilot Business/Enterprise seats. Custom-model output comes straight from the provider and may bypass Copilot's responsible-AI filtering.

Safety — agent-mode terminal commands. Agent mode can only touch files in the open solution, but any terminal command it proposes runs with the permissions of the Visual Studio process — it is not sandboxed. Review proposed commands before letting them run.

codeAmani angle


Common Use Cases

Use Case Approach
Insert/transform text at caret New SDK Command + Editor().EditAsync
Syntax highlighting for a custom language VSSDK IClassifierProvider + ClassificationFormatDefinition
Squiggles / error tags VSSDK ITaggerProvider<IErrorTag>
Inline visuals (CodeLens-like) VSSDK adornment layer + IWpfTextViewCreationListener
Custom IntelliSense IAsyncCompletionSourceProvider
Language server integration New SDK LSP extension contribution
AI codegen / multi-step edits in the IDE GitHub Copilot agent mode (pick a Claude model) or Claude Code CLI in the terminal
CI build of the .vsix dotnet build / msbuild in GitHub Actions (Windows runner)

Troubleshooting

Issue Fix
Extension not loading Check the Experimental Instance: devenv /rootSuffix Exp; reset with /resetSettings
MEF component never constructed ContentType mismatch — verify the [ContentType] matches the open file's type
.vsix not produced Ensure Microsoft.VisualStudio.Extensibility.Build (or the VSSDK targets) is referenced
Stale extension after rebuild Clear the Exp cache under %LocalAppData%\Microsoft\VisualStudio\<version>_*Exp\Extensions — 17.0_*Exp for VS 2022, 18.0_*Exp for VS 2026
msbuild not found in CI Use the microsoft/setup-msbuild action or build with dotnet for SDK-style projects
No Ask / Plan / Agent options in Copilot Chat You're below VS 17.14 (check Help > About), or Enable Agent mode is off under Tools > Options > GitHub > Copilot > Copilot Chat

codeAmani notes

Official docs:

Webhooks Developer Guide

Technology: webhooks · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/webhooks

Insight:

A webhook is a reverse API call — the provider POSTs an event to a public URL you own, so the whole game is proving the request is genuine before acting on it: verify the HMAC-SHA256 signature over the raw body, ACK 2xx in under a second, then do the slow work on a queue. Delivery is at-least-once, so every handler must be idempotent (dedupe on the event id) and replay-safe (reject stale timestamps). Most providers converge on the Standard Webhooks spec — Svix (and therefore Clerk) implement it, Stripe's constructEvent is a close variant, GitHub signs the body as X-Hub-Signature-256. The odd one out for codeAmani is M-Pesa/Daraja: callbacks are unsigned, so verify by source IP + dedupe on CheckoutRequestID and always pair with a reconciliation query.

██╗    ██╗███████╗██████╗ ██╗  ██╗ ██████╗  ██████╗ ██╗  ██╗███████╗
██║    ██║██╔════╝██╔══██╗██║  ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝
██║ █╗ ██║█████╗  ██████╔╝███████║██║   ██║██║   ██║█████╔╝ ███████╗
██║███╗██║██╔══╝  ██╔══██╗██╔══██║██║   ██║██║   ██║██╔═██╗ ╚════██║
╚███╔███╔╝███████╗██████╔╝██║  ██║╚██████╔╝╚██████╔╝██║  ██╗███████║
 ╚══╝╚══╝ ╚══════╝╚═════╝ ╚═╝  ╚═╝ ╚═════╝  ╚═════╝ ╚═╝  ╚═╝╚══════╝

Webhooks Developer Guide

Focus: receive webhooks safely — verify the signature, ACK in under a second, then do the slow work on a queue. The same three moves work for Stripe, Clerk, Sentry, GitHub and M-Pesa/Daraja.

Overview

A webhook is a reverse API call: instead of you polling a provider for "did anything happen yet?", the provider POSTs a small JSON event to a URL you own the moment something happens — a payment succeeded, a user signed up, an error spiked. It is the push half of an event-driven system, and it is the cheapest way to react in near-real-time.

That convenience comes with three hard truths every receiver must respect:

  1. The internet is hostile. Your endpoint is public, so anyone can POST to it. You must cryptographically verify every request actually came from the provider — usually an HMAC-SHA256 signature over the raw body.
  2. Delivery is at-least-once, not exactly-once. Providers retry on timeout or non-2xx. The same event can arrive twice. You must be idempotent — dedupe on the event/delivery id.
  3. Slow receivers get retried (or disabled). Providers expect a fast 2xx ACK (Stripe, Clerk and most others want it within seconds). Do the verification + ACK fast, then push real work onto a queue.

Almost every modern provider has converged on the same wire format, now codified as the Standard Webhooks spec (signed content = id.timestamp.payload, HMAC-SHA256, base64, v1, prefix). Svix is the reference implementation of that spec, and Clerk's webhooks are Svix. Stripe uses a close variant. Once you understand one, you understand them all.

Official Documentation

Source URL What it covers
Standard Webhooks spec https://www.standardwebhooks.com/ The open signature scheme, headers, replay protection
Svix docs https://docs.svix.com/ Webhook.verify(), header names, secret format
Stripe webhooks https://docs.stripe.com/webhooks Delivery, retries, event ids, raw-body requirement
Stripe signatures https://docs.stripe.com/webhooks/signature constructEvent, Stripe-Signature, tolerance
Clerk webhooks https://clerk.com/docs/webhooks/overview Clerk events delivered via Svix
GitHub deliveries https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries X-Hub-Signature-256, sha256 HMAC

Anatomy of a delivery

An event happens on the provider's side → it serialises a JSON payload → it computes a signature over id + timestamp + payload with your shared secret → it HTTP POSTs the payload plus signature headers to your URL → your endpoint verifies, processes, and replies 2xx → the provider marks the delivery acknowledged. Any other reply (or a timeout) triggers a retry.

sequenceDiagram
    participant P as Provider
    participant E as Your endpoint
    participant Q as Queue/Worker
    P->>P: Event occurs · sign payload
    P->>E: POST /webhooks · body + signature headers
    E->>E: Verify signature + timestamp
    E->>Q: Enqueue slow work
    E-->>P: 200 OK ACK fast
    Q->>Q: Process out of band
    Note over P,E: No 2xx in time then retry with backoff

The signature headers carry everything verification needs. Under the Standard Webhooks / Svix scheme:

Header Meaning
webhook-id / svix-id Unique message id — your idempotency key
webhook-timestamp / svix-timestamp Unix seconds when sent — used for replay protection
webhook-signature / svix-signature Space-separated v1,<base64 hmac> values

Stripe folds all three into one Stripe-Signature header (t=<ts>,v1=<hex>); GitHub sends X-Hub-Signature-256: sha256=<hex>. Same idea, different packaging.


The receiver pattern: verify → ACK fast → enqueue slow work

The single most important architectural decision: do not do real work inside the request. Verify, persist a dedupe record, return 2xx, and hand the heavy lifting (DB writes, emails, fulfilment) to a background queue. This keeps you under the provider's timeout and means a slow downstream service never causes a retry storm.

flowchart TD
    A["POST arrives"] --> B{"Signature valid?"}
    B -->|"no"| R1["Return 400 · reject"]
    B -->|"yes"| C{"Timestamp fresh?"}
    C -->|"no · too old"| R2["Return 400 · replay"]
    C -->|"yes"| D{"Event id seen before?"}
    D -->|"yes"| R3["Return 200 · dedupe noop"]
    D -->|"no"| E["Store id · enqueue work"]
    E --> F["Return 200 · ACK"]

Notice a duplicate still returns 200 — you have already done the work, so you just acknowledge and move on. Only a bad signature or stale timestamp earns a 4xx.


Signature verification

This is non-negotiable. An unverified webhook endpoint is a public, unauthenticated POST handler that can mutate your database. Three rules:

Standard Webhooks / Svix (Clerk uses this)

The svix package (v2.x) implements the spec; pass it the raw body and the three headers. verify() throws on any failure (bad signature or stale timestamp — Svix enforces a ±5 minute tolerance internally), and returns the parsed payload on success.

Clerk's shortcut: in a Next.js app, verifyWebhook(req) from @clerk/nextjs/webhooks wraps this Svix flow, reads CLERK_WEBHOOK_SIGNING_SECRET, and pulls the headers for you — no manual svix install or header plumbing. The raw-Svix flow below shows the mechanism underneath and stays useful in non-Next runtimes. See ../clerk/CLAUDE_CODE_INTEGRATION.md.

// app/api/webhooks/clerk/route.ts  (Next.js App Router)
import { Webhook } from "svix";

const SECRET = process.env.CLERK_WEBHOOK_SIGNING_SECRET!; // "whsec_..." (renamed from CLERK_WEBHOOK_SECRET)

export async function POST(req: Request): Promise<Response> {
  const payload = await req.text(); // RAW body — do not JSON.parse first
  const headers = {
    "svix-id": req.headers.get("svix-id") ?? "",
    "svix-timestamp": req.headers.get("svix-timestamp") ?? "",
    "svix-signature": req.headers.get("svix-signature") ?? "",
  };

  let evt: { type: string; data: unknown };
  try {
    // Verifies HMAC-SHA256 over `${id}.${timestamp}.${payload}` AND the
    // timestamp tolerance in one call. Throws on any mismatch.
    evt = new Webhook(SECRET).verify(payload, headers) as typeof evt;
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

  // evt is trusted from here. ACK fast, enqueue the rest.
  await enqueue(headers["svix-id"], evt);
  return new Response("ok", { status: 200 });
}

Stripe (constructEvent)

Stripe verifies and parses in one call. It requires the raw body string/Buffer, the Stripe-Signature header, and the endpoint's signing secret (whsec_...). It also enforces a default 5-minute timestamp tolerance, so replay protection is built in.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const ENDPOINT_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; // "whsec_..."

export async function POST(req: Request): Promise<Response> {
  const body = await req.text(); // raw — required for signature check
  const sig = req.headers.get("stripe-signature") ?? "";

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, ENDPOINT_SECRET);
  } catch (err) {
    return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
  }

  await enqueue(event.id, event); // event.id is your dedupe key
  return new Response("ok", { status: 200 });
}

Raw HMAC-SHA256 (the underlying primitive — GitHub / M-Pesa-style)

When a provider has no SDK, verify the HMAC yourself. This is exactly what GitHub's X-Hub-Signature-256 needs. The key move is timingSafeEqual.

import { createHmac, timingSafeEqual } from "node:crypto";

/** Standard-Webhooks-style signed content: `${id}.${timestamp}.${body}`. */
export function verifyHmac(opts: {
  raw: string;        // raw request body
  id: string;         // webhook-id header
  timestamp: string;  // webhook-timestamp header (unix seconds)
  signature: string;  // base64 HMAC (strip any "v1," prefix first)
  secretBase64: string;
  toleranceSec?: number;
}): boolean {
  const tolerance = opts.toleranceSec ?? 300; // 5 min default
  const age = Math.abs(Date.now() / 1000 - Number(opts.timestamp));
  if (!Number.isFinite(age) || age > tolerance) return false; // replay guard

  const key = Buffer.from(opts.secretBase64, "base64");
  const signed = `${opts.id}.${opts.timestamp}.${opts.raw}`;
  const expected = createHmac("sha256", key).update(signed).digest(); // Buffer
  const given = Buffer.from(opts.signature, "base64");

  // Lengths must match before timingSafeEqual, and compare in constant time.
  return expected.length === given.length && timingSafeEqual(expected, given);
}

GitHub differs slightly: it signs only the raw body (no id/timestamp), uses hex encoding, and prefixes with sha256=. Compute createHmac("sha256", secret).update(raw).digest("hex"), prepend sha256=, and timingSafeEqual against X-Hub-Signature-256.


Idempotency: dedupe on the event id

Because delivery is at-least-once, design every handler so that processing the same event twice has the same effect as processing it once. The cleanest way: a unique constraint on the event id, and let the database reject the duplicate.

import { createClient } from "@supabase/supabase-js";
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

/** Returns true if this is the FIRST time we have seen this id. */
async function claimEvent(eventId: string, type: string): Promise<boolean> {
  const { error } = await db
    .from("webhook_events") // PK / unique on event_id
    .insert({ event_id: eventId, type, received_at: new Date().toISOString() });
  if (error?.code === "23505") return false; // unique_violation → already handled
  if (error) throw error;
  return true;
}

async function enqueue(eventId: string, evt: { type: string }): Promise<void> {
  if (!(await claimEvent(eventId, evt.type))) return; // duplicate → noop, still ACK
  // ...push to your real queue / do the work...
}
flowchart TD
    A["Event id arrives"] --> B["INSERT id into events table"]
    B --> C{"Unique violation?"}
    C -->|"yes · duplicate"| D["Skip work · return 200"]
    C -->|"no · first time"| E["Do work once"]
    E --> F["Return 200"]
    D --> G["Retry storm absorbed"]
    F --> G

If you cannot use a DB unique constraint, fall back to a short-TTL cache (Redis SET key NX EX 86400) keyed on the id. Either way: the id is the dedupe key, not the payload contents.


Retries & at-least-once delivery

Providers retry when you don't return 2xx in time. Knowing the schedule helps you reason about duplicates and reconciliation:

Provider Retry behaviour
Stripe Exponential backoff up to ~3 days (live); a few hours in sandbox. New signature/timestamp per attempt.
Svix / Clerk Exponential backoff over ~24h, then the endpoint may be disabled.
GitHub Limited retries; redeliverable manually from the UI/API.
M-Pesa/Daraja Effectively single-shot — design a polling/reconciliation fallback (see below).

Implications: return 2xx only after you've durably accepted the event (dedupe row written / queued). If your worker fails after you ACKed, the provider won't retry — your queue's own retry must cover it.


Replay-attack protection

A captured-and-replayed request has a valid signature, so the signature alone can't stop it. The defence is the timestamp: reject anything outside a tolerance window (commonly ±5 minutes). Svix and Stripe enforce this for you; in raw HMAC code you do it yourself (see verifyHmac above). Combined with idempotency, a replay either fails the freshness check or is deduped as a known id.


Ordering caveats

Webhooks are not ordered. customer.subscription.updated can arrive before ...created; a payment callback can land before the STK Push response you're still writing. Never assume sequence. Defences:


Local testing

Webhook callbacks must hit a public HTTPS URL, so localhost:3000 alone won't work. Two routes:

# 1. Tunnel localhost to a public HTTPS URL
ngrok http 3000
# → https://abc123.ngrok-free.app  (use this as the webhook URL in the provider dashboard)

# 2. Provider CLIs forward events straight to localhost (no tunnel, auto-signed)
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger payment_intent.succeeded   # fire a test event
gh webhook forward --repo owner/repo --url http://localhost:3000/api/webhooks/github

The Stripe CLI prints a whsec_... for the listen session — use that secret locally, not your live endpoint secret.


Observability & debugging


Security checklist


codeAmani notes

Official docs:

WhatsApp Business API Integration Guide

Technology: whatsapp-business-api · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/whatsapp-business-api

Insight:

WhatsApp is Kenya's dominant chat app. The operational crux: you can only send free-form messages within a 24-hour window of the user's last message — outside it you need pre-approved template messages. Choose Meta Cloud API (cost/control) vs Twilio (speed/unification); same +254… E.164 trap as Africa's Talking.

██╗    ██╗██╗  ██╗ █████╗ ████████╗███████╗ █████╗ ██████╗ ██████╗
██║    ██║██║  ██║██╔══██╗╚══██╔══╝██╔════╝██╔══██╗██╔══██╗██╔══██╗
██║ █╗ ██║███████║███████║   ██║   ███████╗███████║██████╔╝██████╔╝
██║███╗██║██╔══██║██╔══██║   ██║   ╚════██║██╔══██║██╔═══╝ ██╔═══╝
╚███╔███╔╝██║  ██║██║  ██║   ██║   ███████║██║  ██║██║     ██║
 ╚══╝╚══╝ ╚═╝  ╚═╝╚═╝  ╚═╝   ╚═╝   ╚══════╝╚═╝  ╚═╝╚═╝     ╚═╝

██████╗ ██╗   ██╗███████╗██╗███╗   ██╗███████╗███████╗███████╗     █████╗ ██████╗ ██╗
██╔══██╗██║   ██║██╔════╝██║████╗  ██║██╔════╝██╔════╝██╔════╝    ██╔══██╗██╔══██╗██║
██████╔╝██║   ██║███████╗██║██╔██╗ ██║█████╗  ███████╗███████╗    ███████║██████╔╝██║
██╔══██╗██║   ██║╚════██║██║██║╚██╗██║██╔══╝  ╚════██║╚════██║    ██╔══██║██╔═══╝ ██║
██████╔╝╚██████╔╝███████║██║██║ ╚████║███████╗███████║███████║    ██║  ██║██║     ██║
╚═════╝  ╚═════╝ ╚══════╝╚═╝╚═╝  ╚═══╝╚══════╝╚══════╝╚══════╝    ╚═╝  ╚═╝╚═╝     ╚═╝

WhatsApp Business API Integration Guide

Focus: Reach Kenyan/East-African users on their dominant chat app via two paths — Meta Cloud API (direct, lowest cost, full control) or Twilio (a wrapper that unifies WhatsApp with SMS/Voice and smooths webhooks). The operational crux for both is the 24-hour session window + pre-approved template messages.

Overview

Here's the big picture at a glance — two routes to the same WhatsApp user, with the 24-hour window deciding session vs template:

flowchart TD
  App["Your app"] --> Meta["Meta Cloud API<br/>REST Graph API"]
  App --> Twilio["Twilio<br/>messages.create wrapper"]
  Meta --> WA["WhatsApp"]
  Twilio --> WA
  WA --> User["Kenyan user"]
  User -->|"inbound message"| Webhook["Your webhook<br/>verify signature"]
  Webhook -->|"within 24h"| Session["Free-form session message"]
  Webhook -->|"outside 24h"| Template["Pre-approved template message"]

WhatsApp Business has no single SDK — it's a platform with two common integration routes:

Key rule (both paths): you may send free-form session messages only within 24 hours of the user's last inbound message. Outside that window you must send a pre-approved template message (templates are submitted to Meta and categorized: marketing, utility, authentication).

Official Documentation

Resource URL
Meta Cloud API https://developers.facebook.com/docs/whatsapp/cloud-api
Cloud API — send messages https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages
Cloud API — webhooks https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks
Business Management API — message templates https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/
Twilio WhatsApp https://www.twilio.com/docs/whatsapp
Twilio Node SDK https://github.com/twilio/twilio-node

Path A — Meta Cloud API (REST)

You've got this — here's the full Cloud API round trip, from opening a conversation with a template to handling the user's reply inside the 24h window:

sequenceDiagram
  participant You as "Your server"
  participant Graph as "Graph API"
  participant WA as "WhatsApp"
  participant User as "User"
  You->>Graph: POST messages type template
  Graph->>WA: deliver template message
  WA->>User: order_confirmation
  User->>WA: replies within 24h
  WA->>You: webhook with X-Hub-Signature-256
  You->>You: verify HMAC-SHA256 of raw body
  You->>Graph: POST messages type text free-form
  Graph->>User: Hello

Get a Phone Number ID + System User Access Token from the Meta dashboard, then POST to the Graph API:

# Free-form text (only valid inside the 24h window)
curl 'https://graph.facebook.com/v26.0/<PHONE_NUMBER_ID>/messages' \
  -H 'Authorization: Bearer <ACCESS_TOKEN>' -H 'Content-Type: application/json' \
  -d '{
    "messaging_product": "whatsapp",
    "recipient_type": "individual",
    "to": "254712345678",
    "type": "text",
    "text": { "body": "Hello!" }
  }'
# Template message (required to OPEN a conversation / outside the 24h window)
curl 'https://graph.facebook.com/v26.0/<PHONE_NUMBER_ID>/messages' \
  -H 'Authorization: Bearer <ACCESS_TOKEN>' -H 'Content-Type: application/json' \
  -d '{
    "messaging_product": "whatsapp",
    "to": "254712345678",
    "type": "template",
    "template": { "name": "order_confirmation", "language": { "code": "en" } }
  }'

Verify inbound webhooks with the X-Hub-Signature-256 header (HMAC-SHA256 of the raw body using your app secret) before processing.

Creating & getting templates approved

The order_confirmation template above doesn't exist until you submit it to Meta and it's approved. Every template carries a category — UTILITY (transactional: receipts, OTP-free order updates), MARKETING (promos, re-engagement), or AUTHENTICATION (one-time passcodes) — and Meta prices and reviews them by category. After you submit, the template enters PENDING, then moves to APPROVED or REJECTED (with a rejection reason); only an APPROVED template can be sent. Templates are created against your WhatsApp Business Account (WABA) ID, not the Phone Number ID used to send.

flowchart TD
  Draft["Draft template<br/>name · language · category"] --> Submit["POST WABA_ID message_templates"]
  Submit --> Pending["PENDING<br/>under Meta review"]
  Pending --> Approved["APPROVED<br/>safe to send"]
  Pending --> Rejected["REJECTED<br/>fix and resubmit"]
  Approved --> Send["Send via Phone Number ID"]
# Create a UTILITY template (submitted for approval -> PENDING)
curl 'https://graph.facebook.com/v26.0/<WABA_ID>/message_templates' \
  -H 'Authorization: Bearer <ACCESS_TOKEN>' -H 'Content-Type: application/json' \
  -d '{
    "name": "order_confirmation",
    "language": "en",
    "category": "UTILITY",
    "components": [
      {
        "type": "BODY",
        "text": "Hi {{1}}, your order {{2}} is confirmed. Asante!",
        "example": { "body_text": [["Amani", "KE-1024"]] }
      },
      { "type": "FOOTER", "text": "codeAmani Labs" }
    ]
  }'

The {{1}}/{{2}} positional placeholders require an example so reviewers can see real values; when you later send, you fill them via the template's parameters. Check status before sending — filter by name on the WABA endpoint:

# Confirm APPROVED before sending outside the 24h window
curl 'https://graph.facebook.com/v26.0/<WABA_ID>/message_templates?name=order_confirmation' \
  -H 'Authorization: Bearer <ACCESS_TOKEN>'
# -> data[].status: PENDING | APPROVED | REJECTED

Gotcha: outside the 24-hour window you can send only APPROVED templates — a free-form text or a PENDING/REJECTED template will be dropped. Categorize honestly: MARKETING templates cost more than UTILITY and a marketing-flavored message submitted as UTILITY will be re-categorized or rejected by Meta, breaking your send flow. (Twilio users: the same Meta-approved templates apply, referenced by contentSid instead of name.)

Path B — Twilio

npm install twilio        # or: pip install twilio
const client = require("twilio")(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN,
);

await client.messages.create({
  from: "whatsapp:+14155238886",     // Twilio WhatsApp sandbox / your approved sender
  to: "whatsapp:+254712345678",      // note the whatsapp: prefix + E.164 with +
  body: "Hello from codeAmani",      // session message; use contentSid for templates
});

Twilio signs inbound webhooks with X-Twilio-Signature — validate it with the SDK's validateRequest helper.

codeAmani notes

Official docs:

WSL — Linux on Windows Developer Guide

Technology: wsl · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/wsl

Insight:

WSL 2 runs a real Linux kernel in a lightweight managed VM beside Windows — 100% syscall compatibility, systemd, Docker, GPU. Ubuntu is the default distro and the one to use: wsl --install gives you the current Ubuntu LTS with systemd already on. The one rule that decides your whole experience: keep project files in the Linux filesystem (/home/you/code), never on /mnt/c. Cross-OS file access is the single thing WSL 1 does faster; storing code on the Linux side makes WSL 2 up to 20× faster on I/O-heavy work (git clone, npm install). It's the right place to run Claude Code on a Windows machine — a native Linux toolchain with Windows interop one explorer.exe . away.

██╗    ██╗███████╗██╗
██║    ██║██╔════╝██║
██║ █╗ ██║███████╗██║
██║███╗██║╚════██║██║
╚███╔███╔╝███████║███████╗
 ╚══╝╚══╝ ╚══════╝╚══════╝

WSL — Linux on Windows Developer Guide

Focus: Everything a developer needs to run Linux on Windows with WSL — install, architecture, Ubuntu distro management and first-run setup, the apt/Node toolchain for the codeAmani stack, the filesystem performance rule, the full command + config reference, systemd, Docker/GPU/USB/GUI, Windows Terminal, editors over Remote-WSL, and running Claude Code inside WSL. Grounded in learn.microsoft.com and Canonical's Ubuntu-on-WSL docs; reviewed 2026-08-23.

Good news: getting a real Ubuntu environment on Windows is now a one-command affair, and once you internalise a single rule (keep your code on the Linux side), it's genuinely fast and pleasant. Work through the diagrams below, copy the commands as you go, and you'll have a production-grade dev box in an afternoon. Let's dive in.

Table of Contents

  1. Overview & architecture
  2. Install & first run
  3. Which Ubuntu? Flavours, versions & the distro manifest
  4. Your Ubuntu user account
  5. WSL 1 vs WSL 2
  6. Distribution management
  7. The filesystem: interop & the performance rule
  8. The Ubuntu toolchain for the codeAmani stack
  9. Command reference
  10. Configuration: .wslconfig vs wsl.conf
  11. systemd & Linux services
  12. Interop: running Windows ↔ Linux
  13. Networking & ports
  14. Docker, databases, GPU, USB & GUI apps
  15. Windows Terminal & shell setup
  16. Editors: VS Code & Cursor over Remote-WSL
  17. Claude Code in WSL
  18. Disposable distros
  19. Automations & dotfiles
  20. Troubleshooting
  21. codeAmani notes

The interactive learn module above this page is a live filesystem-location advisor + WSL1/WSL2 explorer — start there for intuition, then use this reference.


Official Documentation

Resource URL
WSL docs (root) https://learn.microsoft.com/windows/wsl/
Install WSL https://learn.microsoft.com/windows/wsl/install
Basic commands https://learn.microsoft.com/windows/wsl/basic-commands
Set up a dev environment (first run) https://learn.microsoft.com/windows/wsl/setup/environment
Comparing WSL 1 & 2 https://learn.microsoft.com/windows/wsl/compare-versions
Working across filesystems https://learn.microsoft.com/windows/wsl/filesystems
Advanced settings (config) https://learn.microsoft.com/windows/wsl/wsl-config
systemd support https://learn.microsoft.com/windows/wsl/systemd
Networking https://learn.microsoft.com/windows/wsl/networking
Managing disk space https://learn.microsoft.com/windows/wsl/disk-space
Node.js on WSL https://learn.microsoft.com/windows/dev-environment/javascript/nodejs-on-wsl
Ubuntu on WSL (Canonical) https://documentation.ubuntu.com/wsl/

1. Overview & architecture

The Windows Subsystem for Linux lets you run an unmodified Linux distribution (Ubuntu, Debian, Kali, openSUSE, Arch, AlmaLinux, Fedora, …) directly on Windows — Linux apps, utilities, and Bash — without a traditional VM or dual-boot.

WSL 2 (the default) runs a genuine Linux kernel inside a lightweight utility VM, giving 100% system-call compatibility, systemd, Docker, and GPU compute. Each distro is an isolated container inside that shared managed VM — which is why wsl --shutdown stops all of them at once. Here's the whole picture in one diagram; notice the two filesystems, because that distinction decides your performance:

flowchart TB
  PS["PowerShell / Terminal"]
  EXP["File Explorer"]
  APP["Windows apps"]
  subgraph VM["Lightweight utility VM · Hyper-V"]
    K["Real Linux kernel + systemd"]
    U["Ubuntu container<br/>ext4.vhdx"]
    D2["Second distro<br/>own ext4.vhdx"]
    LFS["Linux fs: /home/you/code · FAST"]
    MNT["/mnt/c: Windows C drive · slow across boundary"]
    K --> U
    K --> D2
    U --> LFS
    U --> MNT
  end
  PS -->|wsl.exe interop| K
  EXP -->|"wsl.localhost share"| LFS
  APP -->|mount| MNT

Prerequisites: Windows 11, or Windows 10 version 2004 / build 19041+ for the wsl --install command. (WSL 2 itself needs Windows 11 or Win 10 version 1903 / build 18362+.)

WSL ships from the Microsoft Store, not Windows Update. Since Windows 19044+, wsl --install pulls the Store-serviced package, so new WSL features land without waiting on an OS update. wsl --update keeps it current; wsl --update --web-download works where the Store is blocked by policy.


2. Install & first run

Open PowerShell as Administrator and run one command:

wsl --install

That single command does a surprising amount of work for you — here's the whole flow, start to finish:

flowchart LR
  A["wsl --install"] --> B["Enable WSL + VM Platform"]
  B --> C["Download Linux kernel"]
  C --> D["Set WSL 2 as default"]
  D --> E["Install Ubuntu (latest LTS)"]
  E --> F["Reboot"]
  F --> G["Create Linux user + password"]
  G --> H["sudo apt update && sudo apt upgrade"]

This enables the WSL + Virtual Machine Platform components, downloads the latest Linux kernel, sets WSL 2 as default, and installs Ubuntu. Reboot when prompted. On first launch you create a Linux username + password (separate from Windows — see §4).

# Install a specific distro instead of the default Ubuntu
wsl --list --online                 # see installable distros (alias: wsl -l -o)
wsl --install -d Ubuntu-24.04       # pin a specific Ubuntu LTS
wsl --install -d Ubuntu --no-launch # install now, do first-run setup later
wsl --install --no-distribution     # install WSL itself, no distro yet

# Keep WSL itself up to date
wsl --update
wsl --version                       # confirm WSL / kernel / WSLg versions

Useful --install flags, all current:

Flag Effect
-d, --distribution <Name> Which distro to install (on Windows 11 the bare wsl --install Ubuntu-24.04 also works)
--no-launch Install without running first-run setup
--web-download Fetch from the web instead of the Microsoft Store
--location <Dir> Install the distro's VHD somewhere other than %LOCALAPPDATA% — use this to keep a large distro off a small C: drive
--from-file <file.wsl> Install a tar-based distro image you downloaded yourself (WSL 2.4.4+)
--no-distribution Install the WSL platform only
--inbox Use the in-Windows component instead of the Store package (updates then come via Windows Update)
--enable-wsl1 Also enable the legacy WSL 1 optional component

If wsl --install only prints the help text, WSL is already present — use wsl --install -d <Distro>. If a download hangs at 0.0%, add --web-download.

Immediately after first launch, update the distro. Windows never updates your Linux packages for you:

sudo apt update && sudo apt upgrade -y
lsb_release -dc          # confirm which Ubuntu release + codename you're on

3. Which Ubuntu? Flavours, versions & the distro manifest

wsl --list --online reads a manifest that groups distros by flavour, and each flavour has a default entry plus pinned versions. For Ubuntu that means these names are valid after -d:

Name to pass -d What it is Use it when
Ubuntu The flavour default — tracks the current Ubuntu LTS and follows it forward across point releases Default choice. This is what plain wsl --install gives you
Ubuntu-26.04 Ubuntu 26.04 LTS, pinned You want a release that will not move under you
Ubuntu-24.04 Ubuntu 24.04 LTS, pinned Matching an existing prod base image / CI runner
Ubuntu-22.04 Ubuntu 22.04 LTS, pinned Legacy toolchain that hasn't been ported
Ubuntu-20.04 Ubuntu 20.04 LTS, pinned Reproducing an old bug only

Canonical also publishes an Ubuntu (Preview) app that tracks the current development release — useful for testing, never for a machine you ship from.

wsl --list --online                 # confirm the exact names on YOUR machine
wsl --install -d Ubuntu-26.04       # pinned LTS
wsl --install -d Ubuntu             # rolling-LTS flavour default

Which should you pick? Match production. Our services run on Linux containers, so pin the Ubuntu LTS your Dockerfile's base image uses (FROM node:22-bookworm is Debian, FROM ubuntu:24.04 is 24.04) and you get genuine dev/prod parity. If nothing constrains you, take the flavour default Ubuntu and let it ride the LTS train.

Running two Ubuntus side by side is normal and cheap. Each is an independent container with its own ext4 VHD, users, and packages. A pinned Ubuntu-24.04 for a client project alongside Ubuntu for everything else costs disk, not complexity:

wsl --install -d Ubuntu-24.04
wsl -l -v                    # both listed, each with its own version + state
wsl -d Ubuntu-24.04          # launch the pinned one without changing the default
wsl --set-default Ubuntu     # decide which one bare `wsl` opens

Modern Ubuntu images are tar-based .wsl files, not Store .appx packages. You can download one from ubuntu.com/wsl and install it directly, which is the path to take on a locked-down machine where the Microsoft Store is unavailable:

wsl --install --from-file C:\Downloads\ubuntu-24.04.wsl
# ...or just double-click the .wsl file in File Explorer

4. Your Ubuntu user account

The first launch of any Ubuntu distro prompts for a UNIX username and password. This trips people up more than it should, so, precisely:

passwd                       # change your own password
whoami                       # who am I actually running as?
id                           # uid/gid — matters for /mnt/c permission masks

Forgot the password? Get in as root from the Windows side and reset it:

wsl -d Ubuntu -u root        # root shell, no password needed
passwd yourusername          # set a new one, then `exit`

Change which user launches by default — two ways, depending on how the distro got installed:

# Store/launcher distros (Ubuntu, Ubuntu-24.04, ...)
ubuntu config --default-user johndoe
# /etc/wsl.conf — works for EVERY distro, including imported ones,
# which have no launcher .exe and so cannot use `config --default-user`
[user]
default=johndoe

Then wsl --terminate Ubuntu (or wsl --shutdown) and relaunch — wsl.conf is read at distro start.


5. WSL 1 vs WSL 2

WSL 2 is the recommended default. The official feature comparison:

Feature WSL 1 WSL 2
Windows ↔ Linux integration ✅ ✅
Fast boot, small footprint ✅ ✅
Managed VM ❌ ✅
Full Linux kernel ❌ ✅
Full system-call compatibility ❌ ✅
Performance across OS file systems ✅ ❌
systemd support ❌ ✅
Runs alongside current VMware/VirtualBox ✅ ❌
IPv6 ✅ ✅

Read the table this way: WSL 2 wins everywhere except cross-OS file access — and you neutralise that by keeping files on the matching filesystem (§7). WSL 2 runs up to 20× faster unpacking a tarball and 2–5× faster on git clone / npm install / cmake than WSL 1 — when files live on the Linux side.

Pick WSL 1 only if: files must live on the Windows filesystem and you access them from Linux tools; you need a serial port (WSL 2 has no serial support — USB is covered by usbipd-win); or you have strict host-memory limits.

wsl -l -v                       # which version is each distro on?
wsl --set-version Ubuntu 2      # convert a distro to WSL 2
wsl --set-default-version 2     # default for new installs

Converting between versions rewrites the whole filesystem. On a distro with large projects, wsl --export first — Microsoft explicitly warns conversions can fail mid-flight.


6. Distribution management

wsl -l -v                                    # installed distros + version + state
wsl -l --running                             # only the ones currently up
wsl --set-default Ubuntu                     # set the default distro
wsl -d Ubuntu-24.04                          # launch a specific distro
wsl -d Ubuntu -u root                        # ...as a specific user
wsl ~                                        # open the default distro at $HOME

# Backup / clone / move a distro (export → import)
wsl --export Ubuntu D:\backups\ubuntu.tar    # snapshot to tar  (--vhd for .vhdx)
wsl --import UbuntuClone D:\wsl\clone D:\backups\ubuntu.tar
wsl --import-in-place Ubuntu-Old D:\wsl\ext4.vhdx   # adopt an existing ext4 VHD
wsl --unregister UbuntuClone                 # delete a distro + its disk (irreversible)

# Disk sizing (WSL 2.5+)
wsl --shutdown
wsl --manage Ubuntu --resize 256GB           # grow the ext4 VHD; see `wsl --manage --help`

--export/--import is your portable backup and the way to move a distro off the system drive. --unregister permanently deletes the distro's ext4 VHDX — back up first.

Disk space facts worth knowing. Each distro is an ext4.vhdx allocated a 1 TB maximum by default (512 GB / 256 GB on older WSL releases). The VHD grows on demand but does not shrink on its own when you delete files — that's why a distro that once held a big node_modules keeps eating disk. Enable sparse VHDs so new distros release freed space back to Windows:

# %UserProfile%\.wslconfig
[experimental]
sparseVhd=true

Never touch the VHD from Windows. The files under %LOCALAPPDATA%\Packages\...\LocalState\ are the live Linux disk; editing them with Windows tools corrupts the distro. Reach Linux files through \\wsl.localhost\Ubuntu\... instead (§7).


7. The filesystem: interop & the performance rule

The single most important WSL habit. Get this one right and everything feels fast; get it wrong and you'll blame WSL for being slow when it's really the boundary crossing. Each filesystem is fast from its own OS and slow across the boundary:

flowchart LR
  LT["Linux tools: git, npm, builds"]
  WT["Windows tools: Explorer, editors"]
  LFS["Linux fs: /home/you/code"]
  MNT["Windows fs: /mnt/c"]
  LT -->|FAST| LFS
  WT -->|FAST| MNT
  LT -->|"2-20x SLOWER"| MNT
  WT -->|slow| LFS
Where the files are Path from Linux Path from Windows Fast for
Linux filesystem (ext4 VHDX) /home/you/code \\wsl.localhost\Ubuntu\home\you\code Linux tools ✅
Windows filesystem /mnt/c/Users/you/code C:\Users\you\code Windows tools ✅

✅ Keep your repos in the Linux filesystem (/home/you/...) when you work with Linux tooling (git, npm, build chains). Putting them on /mnt/c forces every file op across the OS boundary and is dramatically slower. \\wsl$ still works as an alias for \\wsl.localhost.

# Jump between worlds
explorer.exe .                       # open the current Linux dir in File Explorer
cd /mnt/c/Users/you/Downloads        # reach the Windows C: drive from Linux
# In Windows File Explorer address bar:  \\wsl.localhost   (or the older \\wsl$)
# Clone into the Linux fs (fast), NOT /mnt/c (slow)
mkdir -p ~/code && cd ~/code
git clone https://github.com/codeAmani-Solutions/your-repo.git

Windows drives mount through DrvFs, and its defaults matter. /mnt/c mounts with umask=022, fmask=000, dmask=000, and metadata disabled — which is why every file on /mnt/c looks 777 and chmod silently does nothing. Turning metadata on gives real Linux permissions on Windows files (and is what makes shell scripts on /mnt/c executable):

# /etc/wsl.conf
[automount]
enabled=true
options="metadata,umask=22,fmask=11"
root=/mnt/

Two more cross-boundary hazards, both worth fixing on day one:


8. The Ubuntu toolchain for the codeAmani stack

This is the sequence that turns a fresh Ubuntu into a box that can build our Next.js 15 / React 19 / TypeScript projects. Run it once, in order.

8.1 Base packages

sudo apt update && sudo apt upgrade -y
sudo apt install -y \
  build-essential \
  git curl wget unzip \
  ca-certificates gnupg lsb-release \
  jq ripgrep fd-find

build-essential is not optional: it pulls in gcc, g++, and make, which any npm package with a native addon (sharp, better-sqlite3, node-gyp fallbacks) needs at install time. Installing it up front turns a whole class of confusing npm install failures into non-events.

8.2 Node.js — use nvm, not apt install nodejs

Microsoft's own guidance is explicit here: the Node in Ubuntu's apt repositories is outdated, and mixing an apt-installed Node with a version manager produces "strange and confusing conflicts". Remove any existing Node first, then install nvm:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
exec $SHELL -l                 # or close and reopen the terminal
command -v nvm                 # should print "nvm"

nvm install --lts              # current LTS — what we build against
nvm install node               # optional: current release, for testing
nvm alias default lts/*        # LTS is what new shells get
node -v && npm -v

With nvm you never sudo npm install -g again — globals land in ~/.nvm, owned by you. Per-project switching works off .nvmrc:

echo "lts/*" > .nvmrc
nvm use                        # honours .nvmrc in the current repo
corepack enable                # pnpm / yarn shims, shipped with Node

Alternatives: fnm (faster, Rust), volta, n, asdf. All fine — pick one and only one. The failure mode is always two managers fighting over $PATH.

8.3 Git + credentials

git config --global user.name  "Your Name"
git config --global user.email "you@codeamani.com"
git config --global init.defaultBranch main
git config --global core.autocrlf input          # never write CRLF from Linux
git config --global pull.rebase true

Reuse the Windows Git Credential Manager so you are not pasting PATs into a Linux shell — GCM stores them in the Windows Credential Manager, encrypted per Windows account:

git config --global credential.helper \
  "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"

The GitHub CLI is worth having too — it authenticates once and covers PRs, issues, and releases:

sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
  | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null
sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
  | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update && sudo apt install -y gh
gh auth login

8.4 Verify the stack end to end

mkdir -p ~/code && cd ~/code
npx create-next-app@latest smoke-test --ts --app --tailwind --eslint
cd smoke-test && npm run dev

Open http://localhost:3000 in the Windows browser — localhost forwarding means it just works (§13). If npm install was fast and the dev server hot-reloads on save, your filesystem placement is right. If file watching misses changes, you are on /mnt/c — move the repo to ~/code.

Why file watching breaks on /mnt/c: inotify events do not propagate across the 9P boundary reliably, so Next.js Fast Refresh and tsc --watch go quiet. This is the same root cause as the slowness, and it has the same fix.


9. Command reference

All from PowerShell/CMD (use wsl.exe from inside Linux):

Command Does
wsl --install [-d <Distro>] Install WSL + a distro
wsl --install --from-file <x.wsl> Install a tar-based distro image (WSL 2.4.4+)
wsl --list --online List installable distros
wsl -l -v List installed distros, version, state
wsl -l --running / -l --quiet Only running distros / names only
wsl --set-version <Distro> <1|2> Convert a distro's WSL version
wsl --set-default-version 2 Default version for new distros
wsl --set-default <Distro> Which distro bare wsl opens
wsl -d <Distro> [-u <User>] Launch a specific distro, optionally as a user
wsl ~ Launch the default distro at $HOME
wsl --update [--web-download] Update WSL itself
wsl --version / wsl --status WSL/kernel/WSLg versions · default distro + version
wsl --shutdown Stop the WSL 2 VM + all distros (apply .wslconfig)
wsl --terminate <Distro> Stop one distro
wsl --export <Distro> <file> Snapshot a distro (--vhd for .vhdx)
wsl --import <Name> <dir> <file> Restore/clone a distro
wsl --import-in-place <Name> <vhdx> Adopt an existing ext4 VHD as a distro
wsl --unregister <Distro> Delete a distro + its disk
wsl --manage <Distro> --resize <size> Resize the distro's VHD (WSL 2.5+)
wsl --mount <DiskPath> / --unmount Attach/detach a physical or virtual disk
wsl hostname -I WSL 2 VM IP address
<Distro> config --default-user <User> Default login user (launcher distros only)

wslconfig.exe, bash.exe, and lxrun.exe are the deprecated originals. Everything is wsl/wsl.exe now.


10. Configuration: .wslconfig vs wsl.conf

Two files, two scopes:

Aspect .wslconfig wsl.conf
Scope Global — the WSL 2 VM, all distros Per-distro (works on WSL 1 too)
Location %UserProfile%\.wslconfig (Windows) /etc/wsl.conf (inside the distro)
Configures RAM, CPUs, swap, networking, kernel, VHD size boot/systemd, automount, network, interop, default user, GPU, timezone
Applied by wsl --shutdown, then relaunch Distro restart (wsl --terminate <Distro>)
# %UserProfile%\.wslconfig  — global WSL 2 VM tuning
[wsl2]
memory=8GB              # cap VM RAM (default: 50% of host)
processors=4            # logical CPUs (default: all)
swap=2GB                # default: 25% of memory, rounded up to the GB
localhostForwarding=true
guiApplications=true    # WSLg
networkingMode=mirrored # better VPN/IPv6 compatibility (Win11 22H2+)
dnsTunneling=true       # default true
firewall=true           # Hyper-V firewall applies Windows rules to WSL
autoProxy=true          # inherit the Windows HTTP proxy
defaultVhdSize=274877906944   # 256GB cap for NEW distros (default 1TB)

[experimental]
autoMemoryReclaim=gradual     # NOTE: experimental section, not [wsl2]
sparseVhd=true                # new VHDs shrink when files are deleted

⚠️ autoMemoryReclaim and sparseVhd live under [experimental], not [wsl2]. Put them in the wrong section and WSL silently ignores them — a config that looks right and does nothing.

# /etc/wsl.conf  — per-distro settings
[boot]
systemd=true
command="service ssh start"    # runs as root at distro start (Win11 / Server 2022)

[automount]
enabled=true
options="metadata,umask=22,fmask=11"   # real Linux perms on /mnt/c files
root=/mnt/
mountFsTab=true

[network]
hostname=amani-dev
generateResolvConf=true        # false if you want to hand-write /etc/resolv.conf

[interop]
enabled=true                   # run Windows .exe from Linux
appendWindowsPath=true         # add Windows PATH to the Linux PATH

[user]
default=johndoe

[gpu]
enabled=true

[time]
useWindowsTimezone=true        # keeps the distro clock on the Windows timezone

The 8-second rule. Closing a distro window doesn't stop it — the subsystem keeps running for roughly 8 seconds. Editing wsl.conf, closing the window, and reopening it often reads the old config. Confirm with wsl -l --running ("There are no running distributions") before relaunching, or just use wsl --terminate <Distro> / wsl --shutdown.

WSL Settings is now a real GUI app in the Start menu, and Microsoft recommends it over hand-editing .wslconfig. Use it when you want a slider; use the file when you want it in your dotfiles repo.


11. systemd & Linux services

Ubuntu installed via wsl --install ships with systemd already enabled — no configuration needed. Check first before you go editing files:

systemctl list-unit-files --type=service   # works ⇒ systemd is running
ps -p 1 -o comm=                           # should print "systemd"

To enable it on a distro that doesn't have it (requires a Store-serviced WSL; wsl --version must be recognised):

sudo nano /etc/wsl.conf
[boot]
systemd=true
wsl --shutdown        # from Windows, then relaunch the distro

Once systemd is PID 1, Ubuntu behaves like a normal server — which is exactly what you want for local Postgres, Redis, or an SSH daemon:

sudo systemctl enable --now postgresql
systemctl status postgresql
journalctl -u postgresql -n 50 --no-pager

Services enabled with systemctl enable start when the distro starts — which is the first time you open a shell in it, not when Windows boots. If you want a service up without opening a terminal, wsl -d Ubuntu -- true from a Windows startup task is enough to boot the distro.

For databases specifically — whether to run Postgres as a systemd service or as a Docker container in WSL — see the local-database guide.


12. Interop: running Windows ↔ Linux

# Run Windows programs from Linux (note the .exe)
explorer.exe .            # open current dir in File Explorer
code .                    # launch VS Code connected to WSL
clip.exe < file.txt       # copy file contents to the Windows clipboard
powershell.exe -c "Get-Date"
notepad.exe config.json
cmd.exe /C dir            # CMD builtins need cmd.exe /C

# Pipe across the boundary
cat report.csv | clip.exe
ls | findstr.exe ".log"   # Linux output → Windows tool
ipconfig.exe | grep IPv4 | cut -d: -f2
# Run Linux commands from Windows
wsl ls -la ~
wsl --cd ~ -- bash -lc "npm run build"
wsl grep -r "TODO" .
dir | wsl grep git

Windows executables invoked from Linux keep the WSL working directory, run as the active Windows user, and show up in Task Manager as if launched from CMD. Names are case-sensitive and must include .exe.

Share environment variables with WSLENV. It is a colon-separated list of variable names, each optionally suffixed with flags: /p translates a path between Windows and Linux form, /l marks a path list, /u sends it only Windows→WSL, /w only WSL→Windows.

# Windows side: make MY_TOKEN visible in WSL, and translate MY_DIR to a Linux path
setx WSLENV "MY_TOKEN/u:MY_DIR/p"

Interop is toggled per-distro in wsl.conf ([interop] enabled, appendWindowsPath). Turning appendWindowsPath=false off speeds up shell startup and tab completion noticeably, at the cost of losing bare code/explorer.exe — a reasonable trade if you script the few you need.


13. Networking & ports

WSL 2's default NAT mode forwards localhost between Windows and Linux, so a dev server on :3000 in Linux is reachable at http://localhost:3000 in your Windows browser. The VM gets its own IP that changes on restart, which is why you should reach services by localhost, never by the VM address.

npm run dev          # Next.js / Vite in WSL → Windows browser at localhost:3000
wsl hostname -I      # the WSL 2 VM IP, if you genuinely need it
ip route show | grep -i default | awk '{ print $3}'   # the Windows host, seen from WSL
# %UserProfile%\.wslconfig — mirrored mode for VPNs / IPv6 / better localhost
[wsl2]
networkingMode=mirrored
dnsTunneling=true
autoProxy=true       # inherit Windows HTTP proxy

[experimental]
hostAddressLoopback=true     # let container↔host traffic use the host's own IPs
ignoredPorts=3000,5432       # let Linux bind these even if Windows uses them

Networking modes, current: nat (default), mirrored, virtioproxy, none, and bridged (deprecated since WSL 2.4.5 — don't start new setups on it). Since WSL 2.3.25, a failing NAT setup falls back to VirtioProxy automatically.

From Windows 11 22H2 + WSL 2.0.9+, Windows Firewall rules apply to WSL automatically (Hyper-V firewall, firewall=true). If a port that worked yesterday is refused today, check the Windows firewall before blaming WSL.


14. Docker, databases, GPU, USB & GUI apps

docker run --rm hello-world          # via Docker Desktop's WSL2 backend
nvidia-smi                           # confirm GPU passthrough (with the Windows driver)

15. Windows Terminal & shell setup

Windows Terminal auto-detects WSL distros as profiles (tabs, splits, themes) and creates a new profile whenever you install a distro. Set your Ubuntu as the default profile so a new terminal window is a Linux shell.

Two settings worth changing immediately in that profile:

# A typical first-hour shell setup inside Ubuntu
sudo apt install -y zsh
chsh -s $(which zsh)                  # optional: switch to zsh (takes effect next launch)
# ~/.bashrc (or ~/.zshrc) — quality-of-life helpers
alias e='explorer.exe .'
alias winhome='cd /mnt/c/Users/$USER'
alias dev='cd ~/code'
export BROWSER='/mnt/c/Program Files/Google/Chrome/Application/chrome.exe'

16. Editors: VS Code & Cursor over Remote-WSL

Install the WSL extension on Windows, then from an Ubuntu shell:

cd ~/code/your-repo
code .          # opens the editor as a client; the server runs inside WSL

The split matters: the UI runs on Windows, while extensions, the integrated terminal, the debugger, and language servers all execute in Linux against your Linux-filesystem files. Full speed, no /mnt/c penalty, and tsc/ESLint see the same paths CI does.

flowchart LR
  UI["Editor UI (Windows)<br/>rendering · keybindings"]
  SRV["Editor server (Ubuntu)<br/>~/.vscode-server"]
  EXT["Extensions · LSP · debugger"]
  FS["/home/you/code"]
  UI <-->|"Remote-WSL channel"| SRV
  SRV --> EXT
  EXT --> FS

17. Claude Code in WSL

WSL is an excellent home for Claude Code on a Windows machine: a native Linux toolchain (the environment most CLIs assume) with Windows interop a command away.

# Inside your Ubuntu distro — Node via nvm first (see §8.2)
nvm install --lts
npm install -g @anthropic-ai/claude-code   # see the claude-api guide for current install

cd ~/code/your-repo      # keep the repo on the LINUX fs for speed
claude                   # launch in the project

Why it clicks:

Bridging from a Windows-side agent: calling wsl.exe from a Windows shell works, but two things bite. Do not pass an env block (Windows env vars leak through WSLENV and mangle the Linux environment), and set MSYS_NO_PATHCONV=1 when invoking from Git Bash or it rewrites /home/you/... into a Windows path. Simplest is to run the agent inside the distro.

Run wsl --update periodically so the kernel + WSL features stay current for whatever you're building.


18. Disposable distros

Because a distro is just a tar file plus a registration, WSL is a genuinely good sandbox: install from an image, do something risky, --unregister, repeat. This is the cheapest isolation available on a Windows dev box.

# Spin up a throwaway Ubuntu from a snapshot of your clean baseline
wsl --export Ubuntu D:\wsl\baseline.tar                 # once, from a pristine distro
wsl --import scratch D:\wsl\scratch D:\wsl\baseline.tar
wsl -d scratch                                          # play here

# ...and burn it down
wsl --terminate scratch
wsl --unregister scratch

Harden the throwaway before running anything you don't trust:

# /etc/wsl.conf inside the scratch distro
[interop]
enabled=false            # no launching Windows .exe from Linux
appendWindowsPath=false

[automount]
enabled=false            # no /mnt/c at all — Windows files are unreachable

That combination is the meaningful part: with interop off and automount off, code in the distro cannot reach the Windows filesystem or start Windows processes. It is not a security boundary against a determined attacker (it is still a shared VM and a shared kernel, and the network is wide open), but it is a solid blast-radius limiter for "run this unfamiliar install script". For stronger isolation and the full pattern catalogue, see sandbox.


19. Automations & dotfiles

# One-shot dev-box bootstrap (idempotent) — see examples/setup.sh for the full version
#!/usr/bin/env bash
set -euo pipefail
sudo apt-get update -y
sudo apt-get install -y git curl build-essential jq unzip ca-certificates
command -v nvm >/dev/null || curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
mkdir -p ~/code
echo "✅ dev box ready"
# Windows-side: a scheduled nightly WSL backup (Task Scheduler / PowerShell)
wsl --shutdown
wsl --export Ubuntu "D:\backups\ubuntu-$(Get-Date -Format yyyyMMdd).tar"
// .claude/settings.json — nudge keeping repos off /mnt/c
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command",
          "command": "case \"$PWD\" in /mnt/c/*) echo 'You are under /mnt/c — Linux-fs (~/) is much faster for git/npm';; esac" }] }
    ]
  }
}

20. Troubleshooting

Issue Fix
wsl --install only prints help WSL already installed — use wsl --install -d <Distro>
Install hangs at 0.0% wsl --install --web-download -d <Distro>
Microsoft Store blocked by policy wsl --install --web-download, or download a .wsl image and wsl --install --from-file
Everything feels slow Repo is on /mnt/c — move it to ~/ (the Linux fs)
File watching / Fast Refresh misses saves Same cause: inotify doesn't cross the /mnt/c boundary. Move the repo to ~/
Forgot the Ubuntu password wsl -d Ubuntu -u root, then passwd <username>
.wslconfig change ignored Run wsl --shutdown, then relaunch. Confirm with wsl -l --running
autoMemoryReclaim / sparseVhd ignored They belong under [experimental], not [wsl2]
wsl.conf change ignored The 8-second rule — wsl --terminate <Distro> before relaunching
systemd not running Add [boot] systemd=true to /etc/wsl.conf, wsl --shutdown; needs a Store-serviced WSL
chmod on /mnt/c does nothing DrvFs has metadata off by default — add options="metadata,..." to [automount]
bad interpreter: /bin/bash^M CRLF line endings — git config --global core.autocrlf input, re-checkout
localhost:3000 unreachable from Windows Confirm the server binds 0.0.0.0/localhost; try networkingMode=mirrored
VM eating RAM Cap it: [wsl2] memory=8GB + [experimental] autoMemoryReclaim=gradual
Disk keeps growing after deleting files The ext4 VHD doesn't auto-shrink — set [experimental] sparseVhd=true for new distros
"No space left on device" wsl --manage <Distro> --resize <size> (WSL 2.5+) after wsl --shutdown
VPN breaks WSL networking [wsl2] networkingMode=mirrored + dnsTunneling=true
Can't access GPU/USB Install the vendor WSL driver on Windows; for USB use usbipd-win
Clock drift after the host sleeps sudo hwclock -s, or keep [time] useWindowsTimezone=true and systemd-timesyncd on
Distro corrupted Recover with [wsl2] safeMode=true, or restore from a wsl --export backup

21. codeAmani notes

Guide Why it pairs with WSL
vscode Remote-WSL setup, per-side extensions, debugging into the distro
cursor Same Remote-WSL split, AI indexing the Linux-side workspace
local-database Postgres/Redis in WSL — systemd service vs Docker container
sandbox Disposable distros and stronger isolation patterns
chrome-devtools Verifying a WSL-hosted dev server in a real Windows browser

Official docs:

WSL Ubuntu Sandbox Guide

Technology: sandbox · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/sandbox

Insight:

A WSL sandbox is three commands used as a discipline: wsl --install --name to stand up a second Ubuntu, wsl --export/--import as snapshot-and-restore, and wsl --unregister to nuke it — so an experiment you can't trust never touches the distro holding your keys and your ~/code. The trade-off has to be stated honestly: WSL 2 distros are containers inside one shared utility VM, so you get separate mount/PID/user/cgroup namespaces but a shared kernel and a shared network namespace, and .wslconfig caps the whole VM, not one distro. For codeAmani it's the right home for risky installs, security-lab tooling, and customer-bug repros — with docker run --rm --network none as the tighter, lighter tier when a single process is all you need.

███████╗ █████╗ ███╗   ██╗██████╗ ██████╗  ██████╗ ██╗  ██╗
██╔════╝██╔══██╗████╗  ██║██╔══██╗██╔══██╗██╔═══██╗╚██╗██╔╝
███████╗███████║██╔██╗ ██║██║  ██║██████╔╝██║   ██║ ╚███╔╝
╚════██║██╔══██║██║╚██╗██║██║  ██║██╔══██╗██║   ██║ ██╔██╗
███████║██║  ██║██║ ╚████║██████╔╝██████╔╝╚██████╔╝██╔╝ ██╗
╚══════╝╚═╝  ╚═╝╚═╝  ╚═══╝╚═════╝ ╚═════╝  ╚═════╝ ╚═╝  ╚═╝

WSL Ubuntu Sandbox Guide

Focus: Standing up a disposable, isolated Ubuntu on Windows for experiments — spin-up, snapshot, blast-radius containment, and a one-command nuke — so a risky install or a customer repro never lands in your real dev distro.

Overview

The wsl/ guide covers WSL as your primary dev environment. This one covers the opposite posture: a distro you expect to break. The whole workflow is four verbs on the same wsl.exe binary you already have — no extra tooling, no VM images to download.

Verb Command Cost
Spin up wsl --install Ubuntu-24.04 --name lab ~1 min, one download
Snapshot wsl --export lab lab-clean.tar.gz --format tar.gz seconds–minutes, one file
Restore wsl --import lab-2 D:\wsl\lab-2 lab-clean.tar.gz seconds
Nuke wsl --unregister lab instant, unrecoverable

The important thing to internalise before you trust it with anything dangerous: all WSL 2 distros run inside one shared lightweight utility VM. Per Microsoft's own architecture note, distros "share the same network namespace, device tree (other than /dev/pts), CPU/Kernel/Memory/Swap, /init binary, but have their own PID namespace, Mount namespace, User namespace, Cgroup namespace, and init process." The hard wall is the Hyper-V boundary between the VM and Windows — not the boundary between two distros.

flowchart TB
  H["Windows 11 host<br/>your files, creds, domain join"]
  H -->|"Hyper-V boundary — the hard wall"| VM
  VM["WSL 2 utility VM<br/>one kernel · one network namespace · one .wslconfig"]
  VM --> DEV["Ubuntu · dev distro<br/>~/code · SSH keys · .env.local"]
  VM --> LAB["Ubuntu-lab · sandbox distro<br/>own mount / PID / user / cgroup ns"]
  LAB --> SNAP["wsl --export lab<br/>lab-clean.tar.gz"]
  SNAP --> REST["wsl --import lab-2<br/>rebuild from clean"]
  LAB --> NUKE["wsl --unregister lab<br/>gone in one second"]
  LAB --> DKR["docker run --rm --network none<br/>one-process sandbox inside the sandbox"]

Which tier do you actually need?

Tier What you get Reset cost Reach for it when
Second WSL distro Own ext4 VHDX + mount/PID/user/cgroup namespaces wsl --unregister — instant A risky curl | bash, a whole toolchain, a repro that needs systemd
Snapshot + import The above, plus a known-good starting point Re-import a tarball — under a minute You need to run the same experiment five times from a clean base
Docker container Everything a distro gets plus its own network namespace and hard cgroup caps Automatic on --rm One process, one command, no persistent state
Full Hyper-V VM Separate kernel, separate network stack, checkpoints Restore checkpoint — minutes Detonating actual malware, or testing kernel modules

Official Documentation

Resource URL
Basic WSL commands (export / import / unregister / manage) https://learn.microsoft.com/windows/wsl/basic-commands
Advanced settings — .wslconfig and wsl.conf https://learn.microsoft.com/windows/wsl/wsl-config
WSL 2 architecture (what's shared, what isn't) https://learn.microsoft.com/windows/wsl/about
Enterprise setup — images, firewall, file-access model https://learn.microsoft.com/windows/wsl/enterprise
Import a custom distro from a tarball https://learn.microsoft.com/windows/wsl/use-custom-distro
Manage disk space / sparse VHDX https://learn.microsoft.com/windows/wsl/disk-space
Hyper-V Firewall (filtering WSL traffic) https://learn.microsoft.com/windows/security/operating-system-security/network-security/windows-firewall/hyper-v-firewall
WSL releases (check what your build supports) https://github.com/microsoft/WSL/releases
docker run reference https://docs.docker.com/reference/cli/docker/container/run/
Docker none network driver https://docs.docker.com/engine/network/drivers/none/

Quickstart — a throwaway Ubuntu in 60 seconds

# Stand up a SECOND Ubuntu named "lab" — --name is what makes a second
# copy of an already-installed distro possible.
wsl.exe --install Ubuntu-24.04 --name lab --no-launch

# Enter it
wsl.exe -d lab

# ...break things...

# Nuke it. Instant. Unrecoverable. That's the point.
wsl.exe --unregister lab

From PowerShell, drop the .exe. Everything below uses wsl (PowerShell form) except where a Linux-side command is being run.

Confirm what's installed and which VM version each distro is on:

wsl.exe --list --verbose
wsl.exe --version          # verify your WSL build supports the flags below
wsl.exe --list --online    # real, current distro names for --install

--list --online today includes Ubuntu-26.04, Ubuntu-24.04, Ubuntu-22.04, Debian, kali-linux, archlinux, FedoraLinux-44, and the AlmaLinux / openSUSE / SUSE / Oracle families. Use the NAME column verbatim.

Placing and sizing the sandbox

Put the sandbox VHDX somewhere you don't mind filling up, and cap it:

wsl --install Ubuntu-24.04 `
  --name lab `
  --location D:\wsl\lab `
  --vhd-size 40GB `
  --no-launch
Install flag Effect
--name <Name> Registers under a custom name — the key to running N copies of one distro
--location <Path> Where the ext4.vhdx lives (default is under %LocalAppData%\wsl)
--vhd-size <Size> Caps the virtual disk (default defaultVhdSize is 1 TB)
--no-launch, -n Register without launching — you configure wsl.conf before first boot
--fixed-vhd Fixed-size rather than dynamically expanding disk
--from-file <Path> Install from a local distro file instead of the store
--version <1|2> Force WSL 1 or WSL 2 for this distro

Snapshot and restore

This is the part that turns a second distro into a sandbox. Get the environment to a known-good state, export it, and from then on every experiment starts from that file.

Take the golden snapshot

# Terminate first so the filesystem is quiescent
wsl --terminate lab

# tar is the portable format; tar.gz / tar.xz trade CPU for size
wsl --export lab D:\wsl\snapshots\lab-clean.tar.gz --format tar.gz

--format accepts tar, tar.gz, tar.xz, and vhd. The older --vhd switch is still accepted and is equivalent to --format vhd. A .vhdx export restores fastest (no untar) but is far larger and only meaningful for WSL 2.

<FileName> can be - for stdout (and - for stdin on import), so an export can be streamed into another tool. Do this from cmd or a POSIX shell, not PowerShell — PowerShell's object pipeline mangles binary streams:

# Git Bash / WSL — stream a snapshot straight to a compressor or a remote host
WSL_UTF8=1 wsl.exe --export lab - | zstd -T0 -19 -o /d/wsl/snapshots/lab-clean.tar.zst

Restore into a fresh distro

wsl --import lab-2 D:\wsl\lab-2 D:\wsl\snapshots\lab-clean.tar.gz --version 2
wsl -d lab-2
Import flag Effect
--version <1|2> WSL version for the new distro
--vhd The source file is a .vhdx, not a tar — WSL copies it to <InstallLocation>

For a .vhdx you want to attach without copying (fast, but the file becomes the live disk):

wsl --import-in-place lab-3 D:\wsl\images\lab.vhdx

The VHD must be formatted ext4.

The reset loop

# One experiment, start to finish
wsl --unregister lab
wsl --import lab D:\wsl\lab D:\wsl\snapshots\lab-clean.tar.gz --version 2
wsl -d lab -- bash -lc './experiment.sh'

Because --unregister deletes the root filesystem outright, this loop is genuinely a few seconds. Treat lab-clean.tar.gz as immutable and never export over it from a dirty distro.

Post-import housekeeping

An imported distro has no launcher executable, which breaks the usual ubuntu config --default-user trick. Use wsl --manage instead:

wsl --manage lab --set-default-user amani     # imported distros boot as root otherwise
wsl --manage lab --set-sparse true            # auto-reclaim freed disk space
wsl --manage lab --resize 60GB                # grow (or shrink) the VHDX
wsl --manage lab --move E:\wsl\lab            # relocate the distro's disk

Or set it inside the distro before you snapshot, in /etc/wsl.conf:

[user]
default=amani

Containing the blast radius

A fresh distro is not contained by default: it automounts your Windows drives and can launch Windows binaries as you. Harden it in /etc/wsl.conf before you take the golden snapshot.

# Inside the sandbox distro
sudo tee /etc/wsl.conf >/dev/null <<'EOF'
# Do not mount C:\ (and friends) into the sandbox at all.
[automount]
enabled = false
mountFsTab = false

# Do not let Linux processes launch Windows binaries, and keep the
# Windows PATH out of $PATH.
[interop]
enabled = false
appendWindowsPath = false

[network]
hostname = lab
generateResolvConf = true

[user]
default = amani

[boot]
systemd = true
EOF

Then, from Windows:

wsl --terminate lab   # settings only apply after the distro fully stops

What each switch buys you:

Setting Without it With it
[automount] enabled=false /mnt/c exposes your entire Windows profile — SSH keys, .env.local, browser profiles — writable as your Windows user The sandbox cannot see Windows files at all
[interop] enabled=false A script in the sandbox can run powershell.exe, explorer.exe, or any Windows binary as you Windows process launch is blocked
[interop] appendWindowsPath=false Windows PATH entries leak into $PATH; a typo can silently invoke a Windows tool Clean Linux-only PATH
[boot] systemd=true No service manager — many repros won't reproduce Real systemctl, matching production

Microsoft's enterprise guidance is explicit about why automount matters: when a Linux binary in WSL touches a Windows file, it does so with the permissions of the Windows user who ran wsl.exe. Root inside the sandbox is not root on Windows — but it is you on Windows, for every file you can reach.

What you still don't get

Be honest with yourself about the boundary, especially before running anything you'd call malware:

For anything genuinely hostile: a full Hyper-V VM (or Windows Sandbox, or a disposable cloud box) — not a WSL distro.


Resource limits with .wslconfig

.wslconfig lives on the Windows side at %UserProfile%\.wslconfig and governs the whole WSL 2 VM. Keeping a runaway make -j$(nproc) in the sandbox from freezing Windows is exactly what it's for.

# %UserProfile%\.wslconfig   —  applies to the VM shared by ALL WSL 2 distros
[wsl2]
memory=8GB              # default: 50% of host RAM
processors=4            # default: all logical processors
swap=4GB                # default: 25% of memory, rounded up to the nearest GB
swapFile=D:\\wsl\\swap.vhdx
defaultVhdSize=64GB     # cap new distro disks (default 1 TB)
vmIdleTimeout=60000     # ms of idle before the VM shuts down (Windows 11)
nestedVirtualization=true
localhostForwarding=true

[experimental]
autoMemoryReclaim=gradual   # reclaim cache slowly instead of dropCache
sparseVhd=true              # every new VHDX is sparse — disk comes back on delete

Notes that will save you an hour:

Per-experiment limits

Since .wslconfig can't cap a single distro, cap the workload instead — systemd-run inside the sandbox:

# Needs [boot] systemd=true in the sandbox's wsl.conf
sudo systemd-run --scope \
  -p MemoryMax=2G -p CPUQuota=200% -p TasksMax=512 \
  ./experiment.sh

(--user scopes work too, but only where systemd has delegated the memory and pids controllers to the user slice — sudo is the version that always applies.)

...or run the experiment in a container, which is the next section.


Networking isolation

WSL's network knobs are all in [wsl2], and all global:

[wsl2]
networkingMode=mirrored   # nat (default) | mirrored | virtioproxy | none
firewall=true             # Windows Firewall + Hyper-V rules filter WSL traffic (default)
dnsTunneling=true         # DNS via virtualization rather than packets — VPN-friendly
autoProxy=true            # inherit the Windows HTTP proxy

Docker containers — the lighter sandbox

When the experiment is one process rather than a whole environment, a container is faster, tighter, and self-cleaning. Run this from inside your normal distro (or Docker Desktop's WSL integration):

# Ephemeral, network-isolated Ubuntu shell. Gone the moment you exit.
docker run --rm -it --network none ubuntu:24.04 bash

A hardened version for running something you actively distrust:

docker run --rm -it \
  --network none \
  --memory 2g --cpus 2 --pids-limit 512 \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m \
  --user 1000:1000 \
  -v "$PWD/artifacts:/out:rw" \
  -w /out \
  ubuntu:24.04 bash
Flag Why it's there
--rm Container and its writable layer are removed on exit — no cleanup discipline required
--network none The container gets a loopback-only namespace. This is the isolation a second WSL distro can't give you.
--memory / --cpus / --pids-limit Real cgroup caps — a fork bomb hits the limit, not your laptop
--cap-drop ALL + --security-opt no-new-privileges No CAP_*, no setuid escalation
--read-only + --tmpfs /tmp Immutable root; scratch space that can't execute
--user 1000:1000 Not root. Without user-namespace remapping, container root maps to host root on the shared kernel
-v ...:/out One narrow, explicit path for results to escape through

Container vs. distro, decided in one line: if you need systemctl, a persistent home directory, or a multi-day environment, use a distro. If you need "run this and forget it", use a container.


The nuke-and-rebuild script

Keep this next to the golden snapshot. It's the whole workflow in one file.

# D:\wsl\reset-lab.ps1
param(
  # No default for $Name: --unregister has no undo, so the caller must say it out loud.
  [Parameter(Mandatory)][string]$Name,
  [string]$Root     = "D:\wsl\lab",
  [string]$Snapshot = "D:\wsl\snapshots\lab-clean.tar.gz"
)

$env:WSL_UTF8 = 1   # otherwise wsl.exe output is UTF-16 and every match fails

if (-not (Test-Path $Snapshot)) { throw "No snapshot at $Snapshot" }
if ($Name -eq "Ubuntu")         { throw "Refusing to nuke the default distro" }

# Only tear down if it is actually registered.
if ((wsl --list --quiet) -contains $Name) {
  wsl --terminate  $Name
  wsl --unregister $Name
  if ($LASTEXITCODE -ne 0) { throw "unregister failed for $Name" }
}

New-Item -ItemType Directory -Force -Path $Root | Out-Null
wsl --import $Name $Root $Snapshot --version 2
if ($LASTEXITCODE -ne 0) { throw "import failed from $Snapshot" }

wsl --manage $Name --set-sparse true

Write-Host "Sandbox '$Name' rebuilt from $Snapshot"
wsl --list --verbose

And the one-way door, spelled out because it deserves it:

wsl --unregister lab

--unregister deletes the distro's root filesystem. There is no recycle bin, no undo, and no prompt. Type the distro name carefully — wsl --unregister Ubuntu and wsl --unregister lab are one character apart in muscle memory.


Scripting wsl.exe from agents and CI

wsl.exe emits UTF-16LE with no BOM by default, which turns into interleaved NUL bytes in any pipeline that assumes UTF-8 — the classic "why is my output U b u n t u". Set WSL_UTF8=1:

WSL_UTF8=1 wsl.exe --list --verbose
$env:WSL_UTF8 = 1
wsl --list --verbose

Two more scripting notes:


codeAmani notes

A sandbox is where risky things go, not where secrets go.

Official docs:

xAI Integration Guide

Technology: xai · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/xai

Insight:

xAI is the real-time grounding tier — Grok's server-side web_search and x_search tools give a frontier reasoning model live web + X data with inline citations, no scraping pipeline needed. Flagship grok-4.6 runs a 500K-token context; grok-4.3 stays on for 1M-token long-context jobs. The API is OpenAI-compatible, so it drops into existing OpenAI-SDK or AI-SDK code with a baseURL/provider swap.

██╗  ██╗ █████╗ ██╗
╚██╗██╔╝██╔══██╗██║
 ╚███╔╝ ███████║██║
 ██╔██╗ ██╔══██║██║
██╔╝ ██╗██║  ██║██║
╚═╝  ╚═╝╚═╝  ╚═╝╚═╝

xAI Integration Guide

Focus: Grok models via the xAI API — real-time Live Search (web + X), reasoning, structured outputs, and image generation, through @ai-sdk/xai (TypeScript) or the official xai-sdk (Python).

Overview

xAI serves the Grok model family at https://api.x.ai/v1. The flagship grok-4.6 is a frontier reasoning model with a 500,000-token context window, text + image input, and access to xAI's signature capability: server-side Live Search tools (web_search, x_search) that let the model browse the web and X in real time and return inline citations. When a job genuinely needs more room than 500K tokens, grok-4.3 stays available as the 1M-token long-context option. The API is OpenAI-compatible — the OpenAI SDK works with a base-URL swap — and xAI also ships an official Python SDK plus first-class support in the Vercel AI SDK.

flowchart LR
  A["Your app code"] --> B{"Which client?"}
  B -->|"TypeScript"| C["@ai-sdk/xai<br/>Vercel AI SDK provider"]
  B -->|"Python"| D["xai-sdk<br/>official gRPC client"]
  B -->|"Any language"| E["OpenAI SDK<br/>baseURL · api.x.ai/v1"]
  C --> F["grok-4.6<br/>500K-token context"]
  D --> F
  E --> F
  F --> G["web_search + x_search<br/>server-side Live Search<br/>with citations"]

Official Documentation

Resource URL
Quickstart https://docs.x.ai/developers/quickstart
Models https://docs.x.ai/developers/models/grok-4.6
Web Search tool https://docs.x.ai/developers/tools/web-search
Streaming https://docs.x.ai/developers/model-capabilities/text/streaming
Console (keys, billing) https://console.x.ai
Python SDK https://github.com/xai-org/xai-sdk-python

SDK Setup

xAI has no official JS SDK; its docs use the AI SDK provider.

npm i @ai-sdk/xai ai
import { xai } from "@ai-sdk/xai"; // reads XAI_API_KEY from env
import { generateText } from "ai";

const { text } = await generateText({
  model: xai("grok-4.6"),
  prompt: "Summarize today's Kenyan mobile-money news.",
});

Custom configuration:

import { createXai } from "@ai-sdk/xai";

const xai = createXai({ apiKey: process.env.XAI_API_KEY });

TypeScript — OpenAI SDK (drop-in compatibility)

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.XAI_API_KEY,
  baseURL: "https://api.x.ai/v1",
  timeout: 360000, // reasoning models can think for a while
});

Python — official xai-sdk

pip install xai-sdk   # Python >= 3.10
from xai_sdk import Client
from xai_sdk.chat import user

client = Client()  # reads XAI_API_KEY from environment
chat = client.chat.create(model="grok-4.6")
chat.append(user("Hello, how are you?"))
response = chat.sample()
print(response.content)

Models

Model ID Aliases Modalities Context Notes
grok-4.6 grok-4.6-latest text + image → text 500K tokens Flagship frontier reasoning model — coding, agentic tasks, knowledge work
grok-4.5 grok-4.5-latest text + image → text 500K tokens Prior-generation reasoning model
grok-4.3 grok-4.3-latest text + image → text 1M tokens Long-context option — reach for it only when >500K tokens is genuinely needed
grok-4.20-0309-reasoning grok-4.20-0309-reasoning-latest text + image → text 1M tokens Reasoning variant (non-reasoning + multi-agent variants also exist)
grok-build-0.1 grok-build-0.1-latest text → text 256K tokens Function calling, structured outputs, reasoning
grok-imagine-image-2.0 — text → image — Image generation (also grok-imagine-image-quality, grok-imagine-image)

Model lineup and pricing move fast — check https://docs.x.ai/developers/models and the console before hardcoding a model ID; prefer -latest aliases only for experiments, pinned IDs in production. grok-4.6 pricing (2026-08): $2/M input ($4/M above 200K tokens), $0.50/M cached, $6/M output ($12/M above 200K).


Core Patterns

Chat Completion (OpenAI-compatible)

curl https://api.x.ai/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $XAI_API_KEY" \
  -d '{
    "model": "grok-4.6",
    "messages": [
      {"role": "system", "content": "You are Grok, a helpful and truthful AI assistant built by xAI."},
      {"role": "user", "content": "What is prompt caching?"}
    ]
  }'

Streaming

Set a long timeout — reasoning models can run for minutes before the first token.

const stream = await client.chat.completions.create({
  model: "grok-4.6",
  messages: [{ role: "user", content: prompt }],
  stream: true,
});

for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}

Live Search — web + X with citations

The killer feature: server-side search tools. Declare them in tools and the model plans, searches, browses, and cites — no tool-execution loop on your side. Inline citations are on by default when web_search is enabled.

curl https://api.x.ai/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $XAI_API_KEY" \
  -d '{
    "model": "grok-4.6",
    "input": [{"role": "user", "content": "What is the latest update from xAI?"}],
    "tools": [{"type": "web_search"}, {"type": "x_search"}]
  }'
from xai_sdk import Client
from xai_sdk.chat import user
from xai_sdk.tools import web_search, x_search

client = Client()
chat = client.chat.create(
    model="grok-4.6",
    tools=[web_search(), x_search()],
)
chat.append(user("What is the latest update from xAI?"))
response = chat.sample()
print(response.content)
print(response.citations)               # source URLs
print(response.server_side_tool_usage)  # per-tool call counts

Scope searches with domain filters (max 5 domains; allowed_domains and excluded_domains are mutually exclusive):

// AI SDK form
xai.tools.webSearch({ allowedDomains: ["centralbank.go.ke"] });
# xai-sdk form
web_search(allowed_domains=["centralbank.go.ke"])

Structured Outputs (AI SDK + Zod)

import { xai } from "@ai-sdk/xai";
import { generateText, Output } from "ai";
import { z } from "zod";

const InvoiceSchema = z.object({
  vendor_name: z.string().describe("Name of the vendor"),
  invoice_number: z.string().describe("Unique invoice identifier"),
  total_amount: z.number().min(0).describe("Total amount due"),
});

const result = await generateText({
  model: xai.responses("grok-4.6"),
  output: Output.object({ schema: InvoiceSchema }),
  prompt: "Extract the invoice fields from this email: ...",
});

Prompt Caching — x-grok-conv-id

Requests carrying the same conversation ID route to the same server, maximizing cache hits. Check usage.prompt_tokens_details.cached_tokens to verify.

const response = await client.chat.completions.create(
  { model: "grok-4.6", messages },
  { headers: { "x-grok-conv-id": conversationId } },
);
console.log(response.usage?.prompt_tokens_details?.cached_tokens);

Image Generation

import { xai } from "@ai-sdk/xai";
import { experimental_generateImage as generateImage } from "ai";

const { image } = await generateImage({
  model: xai.image("grok-imagine-image-quality"),
  prompt: "A boda boda rider at sunset in Nairobi",
});

AI Routing: When to Use xAI

In codeAmani's routing policy, Anthropic Claude stays primary for complex reasoning and code gen. xAI earns its slot when the answer needs live web or X data:

flowchart TD
  A["Incoming task"] --> B{"Needs real-time<br/>web or X data?"}
  B -->|"yes"| C["xAI grok-4.6<br/>web_search + x_search"]
  B -->|"no"| D{"Complex reasoning<br/>or code gen?"}
  D -->|"yes"| E["Anthropic Claude"]
  D -->|"no"| F["Route by cost tier<br/>(DeepSeek / Haiku)"]
Task Recommended
Real-time news / market / social sentiment grok-4.6 + Live Search
"What's happening on X about …" grok-4.6 + x_search
Ground a >500K-token corpus in live data grok-4.3 (1M context) + Live Search
Complex reasoning, agents, code gen Anthropic Claude
Bulk cheap inference DeepSeek / Haiku tier

Environment Variables

# Required — create at https://console.x.ai
XAI_API_KEY=xai-...

codeAmani notes


Troubleshooting

Issue Fix
401 Unauthorized Verify XAI_API_KEY (starts with xai-) and that it's set server-side
Request times out Reasoning models think before answering — raise SDK timeout (docs use up to 3600s)
allowed_domains rejected Max 5 domains; cannot combine with excluded_domains in one request
No citations in response Citations require a search tool (web_search / x_search) in tools
cached_tokens always 0 Send a stable x-grok-conv-id header so requests hit the same cache
Model not found Check current IDs at docs.x.ai/developers/models — lineup rotates quickly

Official docs: