DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

VPC Lattice Explained Simply: How to Wire Your AI Coding Assistant Securely to Internal Services

AI agents are great at calling your own code, but most engineers still expose those endpoints behind public load balancers or messy VPC peering. VPC Lattice lets you treat every microservice as a first‑class, zero‑trust target—no extra NLBs, no open ports. In this guide you’ll see exactly how to connect a Claude‑powered function‑calling Lambda to an internal API using Lattice, all with type‑safe TypeScript.

What Is VPC Lattice and Why It Matters for AI Agents

VPC Lattice (pronounced “lattice”) is a managed service‑to‑service networking layer that lives inside your Amazon Virtual Private Cloud (VPC).

  • Service network – a private DNS zone that lets each registered service talk to any other service by name, without needing a public load balancer.
  • Zero‑trust target – traffic is allowed only after the caller’s identity (IAM role) and the service’s security‑group rules are both satisfied.

In plain English: Think of VPC Lattice as a secure office directory. Instead of giving every employee a public phone number, you hand out internal extensions that only people inside the building can dial, and the building’s guard checks that the caller is who they claim to be before connecting the call.

For AI agents that need to call internal APIs (for example, a Claude function‑calling request that fetches order details), this means:

  1. No public Internet‑Facing Load Balancer (NLB) to manage.
  2. No open inbound ports that could be scanned by outsiders.
  3. The agent’s Lambda function can reach the microservice by a simple DNS name like order-service.lattice.aws.

Quick SDK Peek

Below is a minimal snippet that lists the existing service networks using the EC2 client (@aws-sdk/client-ec2). We use EC2 because VPC Lattice lives inside a VPC, and the EC2 SDK is the entry point for many VPC‑related operations.

import { EC2Client, DescribeVpcEndpointsCommand } from "@aws-sdk/client-ec2";

/** Create a client that talks to the AWS region where your VPC lives */
const ec2 = new EC2Client({ region: "us-east-1" });

async function listLatticeServiceNetworks() {
  // The `DescribeVpcEndpoints` API can also return Lattice service networks.
  const cmd = new DescribeVpcEndpointsCommand({ Filters: [{ Name: "service-type", Values: ["vpclattice"] }] });
  const response = await ec2.send(cmd);
  console.log("Lattice service networks:", response.VpcEndpoints?.map(e => e.VpcEndpointId));
}

listLatticeServiceNetworks().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Key takeaway: VPC Lattice gives you a private DNS name, so your AI‑driven code never needs to know a public IP address.


Setting Up a Service Network for Your Claude Function‑Calling Lambda

Before the Lambda can call the internal API, you need a service network that contains both the Lambda (as a target) and the microservice (as a service). The steps are:

  1. Create a service network – a logical container for all your microservices.
  2. Register the internal API as a Lattice service, pointing it at the private IP/port of the container or EC2 instance that runs the API.
  3. Add the Lambda as a target for that service.
  4. Attach the Lambda to the VPC so it can resolve Lattice DNS names.

Known Gotchas (VPC side)

  • Cold‑start penalty – Lambdas inside a VPC need a NAT Gateway if they also need Internet access (e.g., to download layers). Without it, the function will stall on the first invocation.
  • Security‑group rule limit – each security group can hold only 60 inbound rules. Complex microservice meshes can hit this limit quickly.
  • VPC peering isn’t transitive – if Team A peers its VPC with Team B, and Team B peers with Team C, Team A still cannot talk to Team C directly. Lattice avoids this by keeping everything in one service network.
  • IPv4 address pricing – each Elastic IP (public address) costs $0.005 per hour. By keeping traffic internal, you can remove many of those costs.

Creating the Network (TypeScript)

The following code uses the VPC Lattice client (@aws-sdk/client-vpclattice) to create a service network and a service called order-service. The code is deliberately simple; in a real deployment you would add tags, IAM policies, etc.

import { VPCLatticeClient, CreateServiceNetworkCommand, CreateServiceCommand } from "@aws-sdk/client-vpclattice";

/** Client that talks to the Lattice control plane */
const lattice = new VPCLatticeClient({ region: "us-east-1" });

async function createNetworkAndService() {
  // 1️⃣ Create a service network that will hold all our services
  const netCmd = new CreateServiceNetworkCommand({
    // A human‑readable name helps when you have many networks
    Name: "ai-assistant-network",
    // Optional: tags for cost allocation
    Tags: [{ Key: "project", Value: "claude-assistant" }],
  });
  const netResp = await lattice.send(netCmd);
  const networkId = netResp.ServiceNetwork?.Id;
  console.log("Created service network:", networkId);

  // 2️⃣ Register the internal order API as a Lattice service
  const svcCmd = new CreateServiceCommand({
    Name: "order-service",
    // The DNS name that callers will use
    DnsEntry: { DnsName: "order-service.lattice.aws" },
    // Associate the service with the network we just made
    ServiceNetworkIdentifier: networkId,
    // Port on which the API listens inside the VPC (e.g., 8080)
    // This example uses HTTP; you could also set up TLS.
    // Note: The actual target registration happens later.
    // Here we only create the service placeholder.
    // For a real microservice you would also create a target group.
  });
  const svcResp = await lattice.send(svcCmd);
  console.log("Created Lattice service:", svcResp.Service?.Id);
}

createNetworkAndService().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Tip: Run the above script once, then keep the serviceNetworkId and serviceId in a parameter store so your Lambda can reference them without hard‑coding.


Connecting the Lambda to the Service via Lattice DNS

Now that the service exists, the Lambda can call it simply by using the DNS name that Lattice created (order-service.lattice.aws). The Lambda runs on Node.js 22, receives a Claude function‑calling payload, and forwards the request to the order API.

Lambda VPC Configuration Gotchas

  • require(esm) breakage – Node 22 introduced strict ESM handling. If your Lambda layer still uses CommonJS, you’ll see a silent failure. Stick to pure ESM modules or transpile your layers.
  • SnapStart + VPC – SnapStart (a cold‑start optimisation) does nothing for Lambdas attached to a VPC because the network interface setup dominates the start‑up time.
  • Response streaming – When you stream data back to Claude, you must set Content-Type: application/json and Transfer-Encoding: chunked; otherwise the runtime buffers the entire response.
  • Provisioned Concurrency cost – Even when idle, provisioned concurrency charges accrue. Use it only for latency‑critical paths.

The Lambda Handler

import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
import {
  VPCLatticeClient,
  InvokeEndpointCommand,
  InvokeEndpointCommandInput,
  ServiceUnavailableException,
} from "@aws-sdk/client-vpclattice";
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

/** The Lattice client will call the private order service */
const lattice = new VPCLatticeClient({ region: "us-east-1" });

/**
 * Helper that builds a type‑safe InvokeEndpointCommand input.
 * The `satisfies` keyword guarantees the object matches the expected shape
 * without widening the type.
 */
function buildInvokeInput(payload: unknown): InvokeEndpointCommandInput {
  const base = {
    // The DNS name we defined when we created the service
    ServiceIdentifier: "order-service.lattice.aws",
    // HTTP method and path the order API expects
    HttpMethod: "POST",
    Path: "/getOrder",
    // The JSON body that the order service understands
    Body: Buffer.from(JSON.stringify(payload)),
    // Optional: forward the caller's IAM role for zero‑trust checks
    // (requires the service to trust the Lambda role)
    ClientToken: crypto.randomUUID(),
  } as const satisfies InvokeEndpointCommandInput; // <-- type‑safe guarantee

  return base;
}

/**
 * Main Lambda entry point.
 * It receives a Claude function‑calling request, extracts the arguments,
 * forwards them to the internal order service, and returns the service response.
 */
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
  try {
    // Claude sends a JSON payload under `body`. Parse it safely.
    const claudePayload = event.body ? JSON.parse(event.body) : {};

    // Build a typed command input – the `satisfies` clause above ensures correctness.
    const invokeInput = buildInvokeInput(claudePayload);

    // Send the request through VPC Lattice.
    const cmd = new InvokeEndpointCommand(invokeInput);
    const response = await lattice.send(cmd);

    // Lattice returns the raw bytes from the target service.
    const responseBody = Buffer.from(response.Body as Uint8Array).toString("utf-8");

    return {
      statusCode: 200,
      headers: { "Content-Type": "application/json" },
      body: responseBody,
    };
  } catch (err) {
    // The most common failure at this stage is ServiceUnavailableException,
    // which means the Lattice service isn’t ready (e.g., target registration still pending).
    if (err instanceof ServiceUnavailableException) {
      console.warn("Lattice service not ready:", err);
      return {
        statusCode: 503,
        body: JSON.stringify({ message: "Internal service still initializing, try again shortly." }),
      };
    }

    console.error("Unexpected error:", err);
    return {
      statusCode: 500,
      body: JSON.stringify({ message: "Something went wrong." }),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In plain English: The Lambda doesn’t need to know an IP address or a load balancer ARN. It simply calls order-service.lattice.aws and lets Lattice route the traffic safely.

Why DNS Matters

If you mistakenly use the old VPC endpoint URL (e.g., vpce-0123abcd.execute-api.us-east-1.vpce.amazonaws.com) you’ll get a generic network timeout. Lattice only resolves the service‑network DNS name; anything else falls outside the private mesh and is blocked by security groups. This is a frequent source of confusion for teams migrating from classic VPC endpoints.


Type‑Safe SDK Calls with the satisfies Keyword

TypeScript’s satisfies operator (added in 5.0) lets you check that an object conforms to a type without widening the literal type. This is perfect for AWS SDK commands where you want to be sure you’re sending every required field, yet you still want the inferred literal type for autocompletion.

Example Without satisfies

const badInput: InvokeEndpointCommandInput = {
  // Misspelling a property causes a silent runtime error
  ServiceIdentifer: "order-service.lattice.aws", // ❌ typo
  HttpMethod: "POST",
  Path: "/getOrder",
  Body: Buffer.from("{}"),
};
Enter fullscreen mode Exit fullscreen mode

The compiler will accept the object because the shape is inferred as any after the assignment, and the typo goes unnoticed.

Example With satisfies

const goodInput = {
  ServiceIdentifier: "order-service.lattice.aws",
  HttpMethod: "POST",
  Path: "/getOrder",
  Body: Buffer.from("{}"),
} satisfies InvokeEndpointCommandInput; // ✅ compile‑time check
Enter fullscreen mode Exit fullscreen mode

If you miss a required property or mistype a name, the compiler throws an error right away. In the Lambda code above, buildInvokeInput uses this pattern to guarantee the request is well‑formed before it ever reaches the network.

Tip: Use satisfies whenever you build a static request object for any AWS SDK command. It catches errors early and saves you from chasing obscure 400 Bad Request responses.


Testing the End‑to‑End AI Agent Flow

Testing a Lambda that talks to a private service can feel tricky because you don’t have a public URL to hit with a simple curl. The easiest approach is to invoke the Lambda locally using the AWS SAM CLI (or the newer aws-lambda-ric runner) and point the Lattice client at a mock endpoint that you register temporarily.

Step‑by‑Step Test Script

  1. Spin up a local mock server that pretends to be the order service.
  2. Register the mock as a Lattice target using the SDK – this step is optional if you only want a pure unit test.
  3. Run the Lambda handler locally with a fabricated Claude payload.
  4. Assert that the response contains the expected order data.

Below is a complete script that uses express for the mock server and the handler from the previous section. Save it as test.ts and run ts-node test.ts.

import express from "express";
import { handler } from "./lambda"; // path to the Lambda code
import type { APIGatewayProxyEvent } from "aws-lambda";

/** 1️⃣ Start a tiny HTTP server that mimics the real order service */
const app = express();
app.use(express.json());

app.post("/getOrder", (req, res) => {
  console.log("Mock service received:", req.body);
  // Echo back a fabricated order payload
  res.json({ orderId: "ORD-1234", status: "confirmed", request: req.body });
});

const server = app.listen(8080, async () => {
  console.log("Mock order service listening on http://localhost:8080");

  /** 2️⃣ (Optional) Register the mock as a Lattice target.
   *    In a real CI environment you would call `CreateTargetGroup` and `RegisterTargets`.
   *    Skipping it here keeps the example simple.
   */

  /** 3️⃣ Build a fake API‑Gateway event that Claude would send */
  const fakeEvent: APIGatewayProxyEvent = {
    body: JSON.stringify({ customerId: "CUST-42", items: ["widget"] }),
    headers: {},
    multiValueHeaders: {},
    httpMethod: "POST",
    isBase64Encoded: false,
    path: "/",
    pathParameters: null,
    queryStringParameters: null,
    multiValueQueryStringParameters: null,
    stageVariables: null,
    requestContext: {} as any,
    resource: "",
  };

  /** 4️⃣ Invoke the Lambda handler locally */
  const result = await handler(fakeEvent);
  console.log("Lambda response:", result);

  /** 5️⃣ Simple assertion */
  const parsed = JSON.parse(result.body);
  if (parsed.orderId === "ORD-1234") {
    console.log("✅ Test passed: order data returned as expected");
  } else {
    console.error("❌ Test failed: unexpected payload", parsed);
  }

  // Clean up
  server.close();
});
Enter fullscreen mode Exit fullscreen mode

Key takeaway: By mocking the downstream service and calling the handler directly, you verify the entire path—Claude payload → Lattice DNS resolution → HTTP request → response handling—without needing a live VPC.

Real‑World Integration Test

For a production‑grade pipeline, you would:

  • Deploy the mock target into a dedicated test VPC.
  • Use AWS CloudFormation or CDK to create a temporary Lattice service and target group.
  • Run the same local invocation, but let the Lambda resolve the real DNS name (order-service.lattice.aws).
  • Tear down the test resources automatically after the CI job.

Conclusion

Connecting an AI‑driven Lambda to internal microservices no longer requires a public load balancer or a tangled web of VPC peering. VPC Lattice provides a clean, DNS‑based mesh that enforces zero‑trust principles automatically. By following the steps above—creating a service network, registering your Lambda as a target, calling the service with a type‑safe SDK request, and testing the flow locally—you can ship a secure, low‑latency AI coding assistant that talks to your private APIs with confidence.


The Takeaway

  • VPC Lattice = private DNS + zero‑trust routing for every microservice inside a VPC.
  • No extra NLBs or open ports are needed; the service network handles traffic isolation.
  • Use the service‑network DNS name (order-service.lattice.aws) — old VPC endpoint URLs will just time out.
  • Configure your Lambda in the same VPC and give it a NAT Gateway if it also needs Internet access.
  • Leverage TypeScript’s satisfies to guarantee SDK request shapes at compile time.
  • Test locally with a mock server before deploying; it validates the whole end‑to‑end path without exposing anything publicly.

With these pieces in place, your Claude‑powered function‑calling Lambda can safely call any internal API, letting your AI assistant focus on code—not on network gymnastics. Happy building!


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-09-08 · Primary focus: VPC

All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.

Find an error? Drop a comment — corrections are always welcome.

Top comments (0)