Skip to content
Mekas Cloud Services
Cloud

AI Agent to On-Premises: A PSC Interface Walkthrough

Raj Meka and Mekas Cloud Services
Diagram-style illustration representing an AI agent connecting privately to an on-premises resource

Our earlier piece on private network architecture for Gemini Enterprise covered the concept: Vertex AI Agent Engine runs your agent in a Google-managed tenant project with no default access to your VPC, and reaching a private resource from that agent requires a Private Service Connect (PSC) interface. This post is the part we didn’t cover there — the actual, step-by-step mechanics of setting one up, using the network model most enterprises are actually running: Shared VPC, with a central host project owning the network and the agent living in its own service project.

The one thing to get right before you start

The most common misconception about this setup: assuming you need a proxy in the middle. You don’t — not for reaching an on-premises resource.

Google’s own guidance is specific about this: a PSC interface can route directly to VPC or on-premises destinations within RFC 1918 address space (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). A proxy is only required for two different scenarios, covered later in this post — reaching the public internet when your project sits inside a VPC Service Controls perimeter, or reaching a non-RFC-1918 address. An on-premises Oracle database with a normal private IP is neither of those. It’s reachable directly.

Architecture — Shared VPC model

flowchart LR
    subgraph Tenant["Google-managed tenant project"]
        Agent[AI Agent<br/>Vertex AI Agent Engine]
    end
    subgraph SVC["Service project: ai-platform-prod"]
        NA[Network Attachment<br/>references host subnet]
    end
    subgraph HOST["Host project: network-host"]
        Subnet[psc-interface-subnet /28]
        CR[Cloud Router]
    end
    OnPrem[(On-Premises<br/>Oracle Database)]

    Agent -->|producer-initiated| NA
    NA -.uses subnet from.-> Subnet
    Subnet --> CR
    CR -->|existing Interconnect / HA VPN| OnPrem

Two things distinguish this from a single-project setup. First, the network attachment’s subnet lives in the host project, since that’s where the Shared VPC and its connectivity to on-premises already live. Second, the network attachment resource itself is recommended to live in the service project alongside the agent — it simplifies which project’s IAM you’re managing day to day, and it’s the pattern Google’s own documentation recommends for exactly this reason.

Prerequisites

  • A Shared VPC with an existing host project already connected to on-premises (Cloud Interconnect or HA VPN, with Cloud Router advertising your on-premises routes)
  • A service project attached to that host project, where the agent will be deployed
  • The on-premises Oracle database’s internal IP address (or hostname, once DNS is wired up) and listener port (1521 by default)
  • These APIs enabled in the service project: compute.googleapis.com, aiplatform.googleapis.com, dns.googleapis.com
  • Permissions to create network resources in the host project and modify IAM bindings in both projects

For the worked example, the Oracle database sits on-premises at 10.50.0.10:1521, service name INVPROD, reachable as inventory-db.corp.internal once DNS is wired up — reachable from the VPC today only because Interconnect/Cloud Router is already in place.

Step 1 — Create the network attachment subnet (host project) and network attachment (service project)

The network attachment needs a dedicated subnet — minimum /28 — in the host project, in the same region as your agent deployment. This subnet doesn’t host any VMs; it’s purely the address pool the PSC interface draws from.

hostproject=YOUR_HOST_PROJECT_ID
serviceproject=YOUR_SERVICE_PROJECT_ID
region=us-central1

# Run against the host project
gcloud compute networks subnets create psc-interface-subnet \
  --project=$hostproject \
  --network=YOUR_SHARED_VPC_NAME \
  --region=$region \
  --range=192.168.10.0/28

Now create the network attachment itself in the service project — the recommended placement, per Google’s Shared VPC guidance for PSC interfaces:

# Run against the service project, referencing the host project's subnet
gcloud compute network-attachments create agent-network-attachment \
  --project=$serviceproject \
  --region=$region \
  --connection-preference=ACCEPT_AUTOMATIC \
  --subnets=projects/$hostproject/regions/$region/subnetworks/psc-interface-subnet

ACCEPT_AUTOMATIC auto-accepts the connection from Vertex AI’s PSC interface without a manual approval step — the right choice here, since you’re the one provisioning both sides.

Step 2 — Grant the Vertex AI service agent the roles it needs

This is where the Shared VPC model adds a real, easy-to-miss step compared to a single-project setup: the service agent needs permission on two different projects, not one.

projectnumber=$(gcloud projects describe $serviceproject --format='value(projectNumber)')

# Create the service agent if this is the first Vertex AI resource in the service project
gcloud beta services identity create --service=aiplatform.googleapis.com --project=$serviceproject

# On the HOST project: permission to use the shared network
gcloud projects add-iam-policy-binding $hostproject \
  --member="serviceAccount:service-${projectnumber}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
  --role="roles/compute.networkUser"

# On the SERVICE project (where the network attachment lives): permission to accept the PSC connection
gcloud projects add-iam-policy-binding $serviceproject \
  --member="serviceAccount:service-${projectnumber}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
  --role="roles/compute.networkAdmin"

# DNS peering, wherever the peering target VPC/zone lives (the host project, in this setup)
gcloud projects add-iam-policy-binding $hostproject \
  --member="serviceAccount:service-${projectnumber}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
  --role="roles/dns.peer"

If roles/compute.networkAdmin is broader than your organization allows for a service agent, a custom role scoped to exactly compute.networkAttachments.get, compute.networkAttachments.update, and compute.regionOperations.get covers what’s actually needed.

Step 3 — Allow the network attachment’s subnet to reach on-premises

This firewall rule belongs in the host project, since that’s where the Shared VPC and its route to on-premises live.

gcloud compute firewall-rules create allow-agent-to-onprem \
  --project=$hostproject \
  --network=YOUR_SHARED_VPC_NAME \
  --direction=EGRESS \
  --action=ALLOW \
  --priority=1000 \
  --destination-ranges=10.50.0.0/24 \
  --rules=tcp:1521 \
  --enable-logging

Scope the destination range and port to exactly what the agent needs — here, just Oracle’s default listener port to the specific /24 the database lives in, not a blanket allow to all of on-premises.

Step 4 — Set up name resolution

gcloud dns managed-zones create corp-internal-zone \
  --project=$hostproject \
  --dns-name="corp.internal." \
  --visibility=private \
  --networks=YOUR_SHARED_VPC_NAME

gcloud dns record-sets create inventory-db.corp.internal. \
  --project=$hostproject \
  --zone=corp-internal-zone \
  --type=A \
  --ttl=300 \
  --rrdatas=10.50.0.10

If your host project already has a Cloud DNS forwarding zone pointed at your real on-premises DNS servers, skip creating a duplicate static zone — DNS peering in the next step follows the host project’s existing resolution chain, forwarding zone included.

Step 5 — Deploy the agent with the PSC interface configured

import json
import google.auth
import google.auth.transport.requests
import requests

credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
credentials.refresh(google.auth.transport.requests.Request())
access_token = credentials.token

NETWORK_ATTACHMENT = f"projects/{SERVICE_PROJECT_ID}/regions/{REGION}/networkAttachments/agent-network-attachment"

response = requests.post(
    f"{ENDPOINT}/v1beta1/projects/{SERVICE_PROJECT_ID}/locations/{REGION}/reasoningEngines",
    headers={
        "Content-Type": "application/json; charset=utf-8",
        "Authorization": f"Bearer {access_token}",
    },
    data=json.dumps({
        "displayName": "inventory-agent",
        "spec": {
            "packageSpec": {
                "pickleObjectGcsUri": f"gs://{BUCKET}/{GCS_DIR}/inventory_agent.pkl",
                "requirementsGcsUri": f"gs://{BUCKET}/{GCS_DIR}/requirements.txt",
                "pythonVersion": "3.10",
            },
            "deploymentSpec": {
                "pscInterfaceConfig": {
                    "networkAttachment": NETWORK_ATTACHMENT,
                    "dnsPeeringConfigs": [
                        {
                            "domain": "corp.internal.",
                            "targetProject": HOST_PROJECT_ID,
                            "targetNetwork": "YOUR_SHARED_VPC_NAME",
                        }
                    ],
                }
            },
        },
    }),
)

reasoning_engine_id = json.loads(response.content)["name"].split("/")[5]
print(reasoning_engine_id)

Note dnsPeeringConfigs.targetProject points at the host project — DNS peering follows wherever the actual VPC and its DNS zones live, not necessarily the same project as the network attachment.

Step 6 — The agent code itself

Using python-oracledb in its default “thin” mode, which connects directly without requiring Oracle Instant Client to be installed:

import oracledb

def get_inventory_level(sku: str) -> dict:
    """Looks up current inventory level for a SKU from the on-premises Oracle database.

    Args:
        sku: The product SKU to look up.

    Returns:
        dict with sku, quantity_on_hand, and warehouse_location.
    """
    connection = oracledb.connect(
        user="agent_readonly",
        password=get_db_password_from_secret_manager(),  # never hardcode this
        dsn="inventory-db.corp.internal:1521/INVPROD",
    )
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT quantity_on_hand, warehouse_location FROM stock WHERE sku = :sku",
                sku=sku,
            )
            row = cursor.fetchone()
            if row is None:
                return {"sku": sku, "error": "not found"}
            return {"sku": sku, "quantity_on_hand": row[0], "warehouse_location": row[1]}
    finally:
        connection.close()

The database credential comes from Secret Manager, not an environment variable or config file baked into the deployment package — and the account (agent_readonly) is scoped to read-only access on exactly the table the agent needs, not a shared application credential with broader access.

Validating the connection

response = requests.post(
    f"{ENDPOINT}/v1beta1/projects/{SERVICE_PROJECT_ID}/locations/{REGION}/reasoningEngines/{reasoning_engine_id}:query",
    headers={"Authorization": f"Bearer {access_token}"},
    data=json.dumps({"input": {"input": "What's the inventory level for SKU ABC-123?"}}),
)
print(response.text)

If it times out rather than returning an error, that’s usually a routing or firewall problem in the host project, not a DNS or credentials one — check that the network attachment’s subnet range is actually included in whatever firewall rule governs egress to your on-premises range first.

Reaching the public internet under VPC Service Controls: a Cloud Run–based proxy

Everything above assumes an RFC 1918 on-premises destination, which never needs a proxy. This section covers the genuinely different scenario: the agent’s project is inside a VPC Service Controls perimeter, and the agent needs to reach something on the public internet — a SaaS API, a public data source. Under VPC-SC, the agent’s default internet path (which normally bypasses your VPC entirely, going straight from the Google-managed tenant project to the internet) is blocked, specifically to prevent that path from becoming an unmonitored data-exfiltration route. The fix is the same shape as the on-prem case — route the traffic through your VPC via the PSC interface — but now something inside your VPC has to actually make the outbound call on the agent’s behalf.

We’re using Cloud Run for that instead of a VM, mainly to remove the OS from the list of things to patch and monitor. The tradeoff: Cloud Run has no fixed internal IP of its own, so making it reachable from your VPC (which the PSC interface traffic requires) needs a few more pieces than a VM would — a serverless NEG and an internal Application Load Balancer in front of it.

flowchart LR
    subgraph Tenant["Google-managed tenant project"]
        Agent[AI Agent]
    end
    subgraph SVC["Service project"]
        NA[Network Attachment]
    end
    subgraph HOST["Host project — Shared VPC"]
        ProxySubnet[Proxy-only subnet]
        ILB[Internal Application<br/>Load Balancer]
        NEG[Serverless NEG]
    end
    CloudRun[Cloud Run<br/>proxy service<br/>ingress: internal]
    Internet((Public internet<br/>e.g. https://mekas.com))

    Agent -->|PSC interface| NA
    NA --> ILB
    ILB --> ProxySubnet
    ILB --> NEG
    NEG --> CloudRun
    CloudRun -->|default egress, no NAT needed| Internet

One genuine simplification versus the VM approach: Cloud Run has outbound internet access by default, so there’s no Cloud Router/Cloud NAT to configure for the proxy’s own egress — only for accepting inbound traffic from the VPC.

Deploy the Cloud Run proxy

Rather than a general-purpose forward proxy (which doesn’t map cleanly onto Cloud Run’s request/response model — there’s no straightforward way to do HTTP CONNECT tunneling), this is a small, purpose-built service that forwards to one specific external API and returns the result:

# main.py
from fastapi import FastAPI
import requests

app = FastAPI()

@app.get("/check-mekas-site")
def check_mekas_site():
    response = requests.get("https://mekas.com", timeout=5)
    return {"status_code": response.status_code, "content_length": len(response.content)}
gcloud run deploy agent-proxy \
  --project=$serviceproject \
  --source=. \
  --region=$region \
  --ingress=internal \
  --allow-unauthenticated

--ingress=internal restricts reachability to internal traffic only (which includes traffic from the internal load balancer we’re about to create) — it isn’t reachable from the public internet at all, regardless of the --allow-unauthenticated flag, which just skips Cloud Run’s own IAM-based invoker check for that internal traffic.

Make it reachable from the VPC

# A dedicated subnet Google's regional load balancers use internally — one per region, reusable
# across other internal load balancers you build later.
gcloud compute networks subnets create proxy-only-subnet \
  --project=$hostproject \
  --purpose=REGIONAL_MANAGED_PROXY \
  --role=ACTIVE \
  --region=$region \
  --network=YOUR_SHARED_VPC_NAME \
  --range=10.129.0.0/23

gcloud compute network-endpoint-groups create agent-proxy-neg \
  --project=$serviceproject \
  --region=$region \
  --network-endpoint-type=serverless \
  --cloud-run-service=agent-proxy

gcloud compute backend-services create agent-proxy-backend \
  --project=$serviceproject \
  --region=$region \
  --load-balancing-scheme=INTERNAL_MANAGED \
  --protocol=HTTP

gcloud compute backend-services add-backend agent-proxy-backend \
  --project=$serviceproject \
  --region=$region \
  --network-endpoint-group=agent-proxy-neg \
  --network-endpoint-group-region=$region

The remaining pieces complete the load balancer: a regional SSL certificate (internal Application Load Balancers use a Compute Engine SSL certificate resource, not a Google-managed one), a regional URL map, a regional target HTTPS proxy, and the forwarding rule that actually allocates the internal IP:port the network attachment’s traffic connects to.

# Self-managed certificate — swap in your own cert/key, or an internal CA-issued pair
gcloud compute regional-ssl-certificates create ilb-ssl-cert \
  --project=$serviceproject \
  --region=$region \
  --certificate=cert.pem \
  --private-key=key.pem

gcloud compute region-url-maps create agent-proxy-urlmap \
  --project=$serviceproject \
  --region=$region \
  --default-service=agent-proxy-backend

gcloud compute region-target-https-proxies create ilb-https-proxy \
  --project=$serviceproject \
  --region=$region \
  --url-map=agent-proxy-urlmap \
  --ssl-certificates=ilb-ssl-cert

gcloud compute forwarding-rules create ilb-https-forwarding-rule \
  --project=$serviceproject \
  --load-balancing-scheme=INTERNAL_MANAGED \
  --region=$region \
  --target-https-proxy=ilb-https-proxy \
  --target-https-proxy-region=$region \
  --network=YOUR_SHARED_VPC_NAME \
  --subnet=private-subnet \
  --ip-protocol=TCP \
  --ports=443

--subnet here is the load balancer’s frontend subnet — an existing general-purpose subnet in the host project’s Shared VPC where the internal IP gets allocated from, not the proxy-only subnet created above (that one’s used automatically by the regional managed proxy layer and never referenced directly). The forwarding rule itself can be created from the service project even though the network and subnet live in the host project, same as everything else in this Shared VPC setup — that’s what roles/compute.networkUser on the host project is for.

Point the DNS record at the load balancer’s internal IP instead of a VM’s:

gcloud dns record-sets create agent-proxy.corp.internal. \
  --project=$hostproject \
  --zone=corp-internal-zone \
  --type=A \
  --ttl=300 \
  --rrdatas=<ILB_INTERNAL_IP>

Calling it from the agent

Because this is a purpose-built endpoint rather than a transparent forward proxy, the agent’s tool code calls it directly — no proxies={} dict, no CONNECT tunneling:

def check_mekas_site() -> dict:
    """Checks the status of https://mekas.com via the internal proxy service, which
    reaches the public internet on the agent's behalf.
    """
    response = requests.get(
        "https://agent-proxy.corp.internal/check-mekas-site",
        timeout=5,
    )
    return response.json()

Common mistakes

  • Standing up a proxy for on-premises traffic. If the destination is a normal RFC 1918 address, skip the entire proxy section above — it’s solving a problem you don’t have.
  • Granting IAM roles in the wrong project. In Shared VPC, compute.networkUser goes on the host project; compute.networkAdmin (or the narrower custom role) goes wherever the network attachment actually lives.
  • Forgetting roles/dns.peer. compute.networkAdmin alone is enough for the PSC interface itself, but DNS peering silently fails without the DNS-specific role.
  • Deploying Cloud Run without --ingress=internal. Without it, the service is also reachable from the public internet directly, defeating the point of routing through the VPC in the first place.
  • Hardcoding the on-premises IP. Works on day one, breaks the day someone re-IPs the database and nobody remembers this agent depends on it.

References

Share this post
FAQ

Frequently asked questions

Do I need a proxy to let an agent reach an on-premises resource over a PSC interface?

No. A proxy is only required when the destination is either the public internet under a VPC Service Controls perimeter, or a non-RFC 1918 address. A genuine on-premises resource (an internal IP in the 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16 ranges) is reachable directly through the PSC interface and your existing hybrid connectivity — no proxy needed.

What IAM roles are needed in a Shared VPC setup specifically?

The Vertex AI service agent needs roles/compute.networkUser on the host project (to use the host's shared network) and roles/compute.networkAdmin — or a custom role scoped to compute.networkAttachments.get/update and compute.regionOperations.get — on whichever project holds the network attachment. Add roles/dns.peer wherever DNS peering is configured. All three go to the automatically created [email protected] service agent, not your own user account.

Why Cloud Run instead of a VM for the VPC Service Controls proxy scenario?

A VM proxy is simpler to set up but is a resource you patch, monitor, and pay for continuously. Cloud Run scales to zero and removes the OS entirely from your maintenance surface — the tradeoff is more networking pieces (a serverless NEG, an internal Application Load Balancer, a proxy-only subnet) since Cloud Run has no fixed internal IP on its own. Either is a legitimate choice; we default to Cloud Run for new builds specifically because there's no OS to patch.

Do I need the Cloud Run proxy if I'm only reaching an on-premises database?

No — these are two separate scenarios. Reaching an on-premises RFC 1918 resource (the Oracle database in this post's main example) never needs a proxy, with or without VPC Service Controls. The proxy is specifically for the case where the agent needs to reach the public internet or a non-RFC-1918 address while the project sits inside a VPC-SC perimeter.

Can I use a stable hostname instead of hardcoding an IP address?

Yes, and it's the recommended approach for anything beyond a quick test. DNS peering lets the agent resolve names using your VPC's own Cloud DNS resolution — either a private zone with static records, or a forwarding zone already pointed at your on-premises DNS servers, if your hybrid DNS setup already has one.

Does this work the same way for Gemini Enterprise Agent Platform and Vertex AI Agent Engine?

They're the same underlying capability — Google has been consolidating naming under Gemini Enterprise Agent Platform's Agent Runtime, though the deployment API surface (reasoningEngines) and the PSC interface / network attachment mechanics documented here are shared. If your SDK or console labels things slightly differently than this post, the underlying resources and gcloud commands are the same.

Take the next step to start your journey with us

Transform your IT challenges into tailored solutions with expert support, 24/7.