Google Cloud & Gemini
The third provider column. What agentfootprint adapts on Google Cloud today — the native Gemini provider, Gemini embeddings, Cloud Storage artifacts, Agent Runtime sessions, Memory Bank and ADC-based identity — plus every remaining boundary with the port that fills it and the honest reason it is not an adapter yet.
Google's agent platform is mid-rename and mid-replacement, so this page says what each thing is called today and what actually has a Node surface. agentfootprint is framework-side: it ships adapters for the data-plane pieces and fills everything else through a port you can implement in an afternoon. You bring the project, the credentials and the runtime.
The names changed under everyone
Vertex AI Agent Builder is now Gemini Enterprise Agent Platform;
Agent Engine is now Agent Runtime; Vector Search 2.0 is now
Agent Retrieval. The API resource is still spelled reasoningEngines — the
wire kept the old name. Where this page and a Google doc disagree on a product
name, the API is the one that has not moved.
One SDK is dead, and it is the one most search results point at
@google-cloud/vertexai announced its own removal for 2025-06-24; that date
has passed. Nothing here builds on it. The live SDK is @google/genai, and it
is the only Google package agentfootprint loads.
Where the evidence on this page comes from
Several rows below are marked field-validated — an independent field trial,
2026-08. That is one specific thing: the adapter answered a real request from
the real service, in a live billing-enabled Google Cloud project, run by someone
who is not the author of this library, against the published package. The first round
ran agentfootprint@9.25.0 on Node 22 with @google/genai@2.16.0, created a
dedicated project, and deleted every resource afterwards.
A second round on 2026-08-14 re-ran the published agentfootprint@9.29.0
with @googleapis/aiplatform@31.0.0 and google-auth-library@11.0.2 — this
time against the three new Agent Runtime adapters, a Gemini 3.1 tool loop, and
the identity provider. It confirmed the 9.29 Gemini fix, promoted three rows
below, and found the two defects 9.30.0 repairs. It also left nothing behind:
five temporary reasoning engines, all deleted, total spend under five cents.
It is not "verified in a production field deployment" — no adapter on this site carries that phrase, and this one does not either; a trial is not sustained production traffic. What it does buy you is that the failures below are observed, not anticipated: the door/model matrix, the Agent Runtime recipe and the billing boundary are that trial's findings written down, including the four things it could not make work.
Service → adapter map
Every row: what it is called, which adapter fills it, which door exports it, which peer dependency it lazily requires, what it covers — and what it does not.
| Google service | Adapter | Door | Peer dep | Ops covered | Status | Capability page |
|---|---|---|---|---|---|---|
| Gemini models (Vertex and the Gemini API) | gemini() | agentfootprint/providers | @google/genai | models.generateContent, models.generateContentStream. Not multi-modal input, not thought summaries, not grounding tools (Search, code execution, URL context) | Field-validated on the Vertex door — an independent field trial, 2026-08: a real two-call tool loop and real streaming on gemini-2.5-flash; and on 2026-08-14 a live gemini-3.1-flash-lite tool loop through 9.29's thought-signature round trip, which is the 3.x path that used to 400 after the tool had already run. Not every door/model pair works — read the matrix before you pick one | Gemini |
| Gemini embeddings | geminiEmbedder() | agentfootprint/providers | @google/genai | models.embedContent — Matryoshka sizes, task types, a refusal when the service says it clipped. One text per request, so a batch is N calls | Field-validated on the Vertex door — 2026-08: gemini-embedding-001 at 768 dimensions, indexed through defineRAG. The Gemini-API door was blocked by billing, not by code — see the matrix | Embedders |
| Cloud Storage (artifact store) | gcsArtifacts() | agentfootprint | @google-cloud/storage | bucket.file().save / .download / .getMetadata / .delete, bucket.getFiles, plus native createWriteStream / createReadStream streaming | Field-validated — 2026-08: nine live checks against a real bucket, including scope isolation, native streaming, TTL expiry, budget eviction and the sanitized 404 | Artifacts |
| Cloud Trace / Cloud Logging | otelObservability() pointed at telemetry.googleapis.com — a recipe, not a new adapter | agentfootprint/observe | @opentelemetry/api + the OTel SDK you choose | The whole typed event stream as gen_ai.* spans and span events | Field-validated as the corrected recipe below — 2026-08: one six-span trace read back from Cloud Trace. The recipe this page shipped before that trial did not work; what changed is written out in full | Observability sinks |
| Agent Gateway (egress, MCP passthrough) | gatewayTransport + mcpClient | agentfootprint/providers | @modelcontextprotocol/sdk | MCP over Streamable HTTP with per-request auth headers | Field-validated for an ordinary bearer gateway (2026-08: tool discovery, a real call, five requests → five freshly vended credentials, none stored). 🔴 Not for Google's identity-enforced path: Agent Gateway agent identity is mTLS + DPoP, and this transport has no certificate, signer or custom-fetch hook — see below | Tools & gateways |
| Agent Runtime Sessions | agentEngineSessions() | agentfootprint/hosting | @googleapis/aiplatform | sessions.create / .get / .appendEvent / .delete / .list, sessions.operations.wait. Plus listByUser and ownerOf. State is written by appending an event — the service forbids patching it. Reads never touch the event log: one envelope per session, read out of sessionState | Field-validated, with the write verb field-CORRECTED (9.30.0). The 2026-08-14 trial ran this adapter live: create, hydrate through a fresh instance, owners, paged listByUser, ownerOf, an unknown envelope format refused before storage, idempotent forget — all PASS. The second write failed, because 9.29.0 patched sessionState and Google refuses that; 9.30.0 appends an event instead (the repair the same trial verified on the wire). The corrected write has not itself been re-run live | Hosting & runtime |
| Agent Runtime Memory Bank | memoryBankStore() | agentfootprint/memory | @googleapis/aiplatform | memories.create / .get / .patch / .delete / .retrieve, memories.operations.wait. Not putIfVersion / seen / recordSignature / feedback / getFeedback — refused by name, no primitive exists. Not purge — see below | Field-validated on the data plane — 2026-08-14, this adapter against a real Memory Bank: cross-conversation recall under a widened scope, two identities sharing one entry id without collision, string and JSON values, pagination cursors, tier filtering, similarity with correctly ordered scores, scoped delete and forget(), and the five unsupported ops refusing by name. The same trial found entries losing source and caller metadata; 9.30.0 carries them (that fix is tested, not yet re-run live) | Memory & stores |
| Agent Runtime (hosting) | — | agentfootprint/hosting | — | — | 🔴 No adapter — but Node deploys, verified: the 2026-08 trial ran a Node container through both Agent Runtime routes. The recipe is below; run it on Cloud Run instead if you would rather not own the contract | Hosting & runtime |
Vector Search (v1 Index / IndexEndpoint) | — | agentfootprint/memory | — | — | 🔴 Bring-your-own via MemoryStore — needs a deployed IndexEndpoint, so there is no cheap path to verify one | Memory & stores |
| Identity (ADC, workload identity federation, impersonation) | googleIdentity() | agentfootprint/security | google-auth-library | Google access tokens for Google APIs, from ADC / WIF / an impersonated service account. Not mode: 'user' — refused by name, since Google's per-user vault has no Node surface | Field-validated for machine identity; refresh bounded — 2026-08-14: a real bearer from ADC authorized a Vertex request (HTTP 200), expiry ≈3,599 s out, a second vend without reconstruction, mode: 'user' and a disallowed service both failing closed, and JSON.stringify(credential) yielding {"kind":"bearer"} — no token, no header. Not proven: an expiry-triggered refresh; the run did not span an hour | Identity & credentials |
| Agent Identity auth manager (3-legged OAuth, API-key vault) | — | — | — | — | ⏸️ Parked — Preview with no Node surface as of 2026-08-12 | — |
| Agent Retrieval (ex-Vector Search 2.0) | — | — | — | — | ⏸️ Parked — no Node SDK published as of 2026-08-12 | — |
| Vertex AI Extensions | — | — | — | — | ⛔ Deprecated 2026-05-26, shutdown after 2026-11-26. Do not build on it | — |
| Model Armor / Semantic Governance | — | — | — | — | ⛔ No adapter, deliberately — enforced at the Gateway, in front of your process | Governance & policy |
Control plane (every Create*) | — | — | — | — | 🔴 Bridge it yourself — googleapis, gcloud or Terraform | — |
The two rungs on this page, and the gap between them
Every adapter here is built against a really-installed SDK, with its calls
exercised through an injected _client seam, plus a surface pin that checks
the method names — and the API version — against that installed package on every
test run. That is the contract-shaped and tested; awaiting field use rung,
and it means exactly what it says: this one has not yet answered a request from
Google in a real project.
Field-validated is one rung higher and is claimed only where a trial
actually made the call: gemini() and geminiEmbedder() on the Vertex
door, gcsArtifacts(), the corrected Cloud Trace recipe, gatewayTransport +
mcpClient against an ordinary bearer gateway — and, since the 2026-08-14
second round, agentEngineSessions(), memoryBankStore() and
googleIdentity(), which that round ran live against real Agent Runtime
resources. Where a trial exercised the Google service but not this library's
adapter, the row says so rather than borrowing the credit.
That second round also earned this page a third phrase, used on exactly two
rows: field-corrected. It means the trial ran the shipped code, the service
refused it, and the code changed to what the service actually accepts — the
session write verb (patch → appendEvent) and the Memory Bank entry mapping
(source and caller metadata now carried). Both repairs are tested here and
built on measured service behaviour; neither has been re-run live yet, and the
rows say that too.
No adapter on this page — and none anywhere on this site — is described as verified in a production field deployment. A trial is not production traffic.
The surface pin, and the version nobody states
Every Google adapter's calls are pinned by test in
test/adapters/google/googlePin.ts, with a completeness assertion that fails the
build for any src/** file loading a Google package without a row. It carries the
AWS pin's dispatch and reality checks, plus one Google needs and AWS does not:
The API-version assertion. @google/genai 2.16.0 defaults to v1beta1 on
Vertex and v1beta on the Gemini API — not v1. That is the SDK's choice,
not a setting, and it is exactly the kind of fact a provider page gets wrong: an
adapter that says "GA, v1" while the client dials v1beta1 compiles, passes, and
calls somewhere else. The registry records the version each door resolves to and
the test asks the installed package, so a change arrives as a red test.
The same assertion, aimed the other way, is what settled which client the
Sessions and Memory Bank adapters use. @googleapis/aiplatform takes its
version as an argument rather than defaulting to one, so the pin asserts that
the constant those adapters dial (v1) is a version the installed package
really has and that the surface is reachable at it. That matters because the
versions are not equivalent: memories.purge and memories.rollback exist at
v1 and do not exist at v1beta1.
import { gemini } from 'agentfootprint/providers';
// Leave it unset and you get the SDK's default (v1beta1 on Vertex) — knowingly.
const pinned = gemini({ project: 'my-project', location: 'us-central1', apiVersion: 'v1' });Two more mechanical differences from the AWS pin, both deliberate:
- The packages ARE devDependencies. The AWS SDKs are kept uninstalled so six adapters can prove their missing-peer-dep refusals by real absence — which makes the AWS reality check vacuous in CI. Google is mid-rebrand and its Node surface lags its Python one, so version drift is precisely where the bugs live: the reality and version assertions have to actually run. The missing-peer-dep refusals are proved by stubbing module resolution instead.
- The reality check enumerates the INSTANCE, not just the prototype.
@google/genaiassignsgenerateContent,generateContentStreamandembedContentas instance fields; onlycountTokensis a prototype method. A prototype-only check would call three of our four pinned methods missing.
Gemini — the native provider
import { Agent } from 'agentfootprint';
import { gemini } from 'agentfootprint/providers';
// Vertex: credentials come from Application Default Credentials.
const provider = gemini({ project: process.env.GOOGLE_CLOUD_PROJECT!, location: 'us-central1' });
// …or the Gemini API (AI Studio): one key, no cloud project.
const studio = gemini({ apiKey: process.env.GEMINI_API_KEY! });
const agent = Agent.create({ provider, model: 'gemini' }).tool(weatherTool).build();Two doors, one adapter, and neither is guessed: a project selects Vertex, a key selects the Gemini API, and configuring neither is refused at construction naming both. The deep dive — the mapping table, the tool-call id rule, the honest streaming-usage law — is on Gemini.
The OpenAI-compatible endpoint is a demo path, and this page says so
Google publishes an OpenAI-compatible endpoint, and openai({ baseURL, apiKey })
does reach it. It is not a supported production path here, for four stated
reasons: its bearer token expires after an hour (apiKey now takes a
callback re-read before every request, which is a mitigation, not a fix — a
stream keeps the key it started with); its tool parameters field is OpenAPI,
not JSON Schema, so $ref / oneOf / additionalProperties diverge silently;
unsupported parameters are ignored rather than refused; and its documented
response carries no cached- or reasoning-token counts, so usage.cacheRead
and usage.thinking can only ever be undefined behind it. gemini() answers all
four. Use the compat endpoint to try something in ten seconds, not to ship.
The door/model matrix
Two doors and two model generations do not make four working combinations. The 2026-08 trial ran all of them from one machine, on the same day, with the same package, and got four different answers. This table is that result — every cell is an outcome somebody observed, not a capability statement.
| Door | gemini-2.5-flash | gemini-3.1-flash-lite (current) | gemini-embedding-001 |
|---|---|---|---|
Vertex — gemini({ project, location }), ADC | ✅ Works, and is the only field-verified generate path: two-call tool loop, real streaming, honest usage | ⚠️ Fixed in the code, not yet re-proven in the field. The trial's tool loop failed on the second call — 400 INVALID_ARGUMENT, "Function call is missing a thought_signature in functionCall parts" — after the tool had already run. The adapter now carries that signature back byte-for-byte; that fix is tested, and no live re-run has confirmed it | ✅ Works — 768 dimensions through ADC, indexed and retrieved |
Gemini API — gemini({ apiKey }), an AI Studio key | ⛔ 404 NOT_FOUND — "no longer available to new users". It used to be this door's default too; it is not any more, and the 'gemini' shorthand is refused here by name rather than sending it | ⚠️ Reachable, but the trial's call returned 429 RESOURCE_EXHAUSTED — prepayment credits depleted. That is a billing boundary, not a code failure | ⚠️ Same 429, same reason |
OpenAI-compatible — openai({ baseURL, apiKey: <OAuth token> }) | ✅ with a current token; ⛔ 401 the moment it expires. Pass apiKey as a callback and the token is re-read before every request — the answer to the trial's 401, and still a demo path for the three other reasons above | — | — |
Three traps, all of them observed
1 — The default model is a Vertex default, and the other door has none.
model: 'gemini' is a shorthand for this door's default. On Vertex that
resolves to gemini-2.5-flash, the working choice. On the Gemini API door there
is deliberately no default and the shorthand is refused by name, quoting
the 404 the trial got and naming both fixes — because the alternative is a second
silent default that nobody has ever run. Name your model explicitly on the
Gemini API door.
2 — The working model has a retirement date. Google lists gemini-2.5-flash
for retirement on 2026-10-16, so "use 2.5 on Vertex" is a dated
recommendation rather than a settled one. The current-generation cell beside it is
the one whose tool loop this library had to fix after the trial and has not yet
re-run live — so if your agent calls tools, run your tool loop against the
current model yourself, well before that date.
3 — Tokens expire in an hour, and only one door survives it. A Google OAuth
access token lives about an hour. gemini({ project, location }) reads ADC
through the SDK, so refresh happens underneath you — the trial proved this
directly by force-expiring an injected AuthClient, calling getAccessToken()
again and completing a live model call with the refreshed one. The
OpenAI-compatible door captures a string at construction: it worked with a
fresh token and returned 401 with an expired one, in the same script. Pass
apiKey as a callback there and it is re-read before every request — which
answers the 401, but not the other three reasons that door stays a demo path, and
not a stream already in flight.
Regional hosts, said once for the whole column. location is not decoration.
gemini() and geminiEmbedder() accept global (the multi-region endpoint, and
what the trial used). Sessions and memories are regional resources whose
generated client would otherwise dial the global aiplatform.googleapis.com, so
the adapters derive a regional rootUrl from location for you. Agent Runtime is
regional in its URL (https://<REGION>-aiplatform.googleapis.com/...). Cloud
Trace's OTLP endpoint has both forms — telemetry.googleapis.com and
telemetry.<REGION>.rep.googleapis.com. Mixing regions does not fail loudly; it
bills egress and, for the session store, looks like a conversation that is not
there.
Gemini embeddings
import { geminiEmbedder } from 'agentfootprint/providers';
import { sqliteVectorStore } from 'agentfootprint/memory';
import { indexFolder } from 'agentfootprint/rag';
const embedder = geminiEmbedder({ project: 'my-project', dimensions: 768 });
await indexFolder('./docs', { to: sqliteVectorStore({ file: './corpus.db' }), embedder });gemini-embedding-001 by default: 3072 dimensions, shortenable to any size you
ask for (Matryoshka), a 2,048-token input window, and the real task_type
vocabulary — embed() sends RETRIEVAL_QUERY, embedBatch() sends
RETRIEVAL_DOCUMENT, because that is what this library's two call sites are.
gemini-embedding-2 is also known by name: an 8,192-token window and no
task_type at all, which is refused by name rather than sent and ignored.
Two traps this adapter turns into refusals:
- One text per request.
gemini-embedding-001accepts exactly one input, soembedBatchis N sequential calls. Libraries that batched it like an OpenAI client send oversized requests that fail on every batch of more than one. - Silent truncation. Over the window Gemini does not refuse — it clips, and
a full-looking vector comes back for the opening of the passage. The response's
statistics.truncatedis the service admitting it, andonTruncation: 'refuse'(the default) turns that into an error naming the fix, so a passage is never indexed by a prefix of itself.'allow'is there for when a prefix embedding is genuinely what you want.
Full rows and the shared maxInputChars machinery: Embedders.
Cloud Storage — the artifact store (gcsArtifacts, 9.25.0)
The claim-check store in a Cloud Storage bucket, and the second
Google package this library loads. Same five-verb port as every other adapter — a
one-line swap from fileArtifacts.
Field-validated — an independent field trial, 2026-08
Nine checks against a real bucket (uniform bucket-level access, public-access
prevention on) with @google-cloud/storage@7.22.0: JSON put/head/get with a
verified SHA-256; a wrong tenant, principal and conversation each reading
null or an empty list rather than another scope's object; cursor pagination
across two distinct pages; native putStream/getStream preserving every byte;
a TTL expiring and lazy-deleting; maxCountPerScope evicting oldest-first with
reason max-count; an oversized label refused before upload against the
documented 8 KiB metadata budget; a missing bucket reading as the documented
ambiguous null on a point read while a write raised a sanitized 404 that
did not leak the object key or scope; and delete being idempotent.
Every one of those is a law stated below. That is the point of the list: they were checked in the field in the same words the docs use.
import { gcsArtifacts } from 'agentfootprint';
const artifacts = gcsArtifacts({
bucket: 'my-agent-artifacts', // must already exist — this library never creates one
prefix: 'artifacts', // optional, so a bucket can be shared
projectId: 'my-project',
});
const agent = Agent.create({ provider, artifacts });GcsArtifactsOptions also takes storage (your own pre-built Storage client, so
credentials and configuration stay yours) and retention.
The object name is [<prefix>/]<tenant>/<principal>/<conversation>/<ref> — the
scope partitioned with the same percent-encoding law the directory adapter uses, so
a tenant of literally .. is a name. A wrong scope computes a different name,
the service answers 404, and the caller reads null: one indistinguishable miss,
never a cross-tenant read.
The ticket rides as custom object metadata — one entry of ASCII JSON, nested the
way this client requires ({ metadata: { metadata: { … } } }, an 8 KiB cap checked
at put and refused by name). The payload is the object body as canonical bytes,
so a stored report is downloadable from the console and is the report.
A listing is cheaper on this column, and the reason is a real SDK difference.
getFiles({ autoPaginate: false }) returns each File with its .metadata
already populated, custom entries included — so list() reads a whole page of
tickets in one call, where the other column needs a read per row returned. Worth
knowing when you are choosing where a chatty listing lives.
Retention works exactly as everywhere else: ttlMs stamps expiresAt at
mint (stated, never sprung), expiry is enforced on read and swept on the way
past, and budgets evict oldest-first. A put with no budget dials configured does not
scan the scope.
Bulk reclamation is the operator's job, through Object Lifecycle Management.
This adapter never creates a rule. Keep the rule longer than the store's
ttlMs, never shorter — the store's expiresAt is the promise consumers read on
the ticket, and a lifecycle rule that deletes first makes a live ticket resolve to
null before the time it printed:
{ "rule": [{ "action": { "type": "Delete" },
"condition": { "age": 7, "matchesPrefix": ["artifacts/"] } }] }Streaming is native: putStream / getStream ride createWriteStream /
createReadStream, bridged to web streams at the adapter's edge — the bridge never
leaks into the port. Feature-detect before calling; see Artifacts.
Failures never leak the object name. The name carries this run's tenant,
principal and conversation, so the SDK's own message is withheld and what comes
through is the operation, the exception's name and the status. Every failure,
including a 404: the only 404 that comes back raw is one from a call that named one
object (head, get, delete), and that one never leaves the adapter, because the
line that asked converts it to null on the spot.
Which 404s are "no data", said honestly. On this column a missing object and a
missing bucket arrive identically — code: 404, reason notFound for both, telling
you which only in prose the adapter refuses to parse. So the split is made by the
call instead: a read of one named object may answer null; a save or a listing
may not, and a 404 from one of those raises. A store pointed at a bucket that does
not exist therefore reads as empty and says so plainly on the first write — never a
made-up classification of an error it cannot actually tell apart.
IAM: storage.objects.create, .get, .delete and .list on the bucket
(roles/storage.objectAdmin covers it). Peer dependency
@google-cloud/storage, loaded lazily at construction — a missing install
refuses where the config was written, and a browser bundle never sees it.
How it fits with the rest of this column. The artifact store and the session
store are two halves of one deployment, and they compose because they scope
identically: an artifact's object name is
[<prefix>/]<tenant>/<principal>/<conversation> and Memory Bank's default scope
is the same { tenant, principal, conversation } tuple, so a run's artifacts and
its memories partition the same way with no extra wiring.
import { Agent, gcsArtifacts } from 'agentfootprint';
import { standingAgent, nodeHost, agentEngineSessions } from 'agentfootprint/hosting';
import { memoryBankStore } from 'agentfootprint/memory';
import { googleIdentity } from 'agentfootprint/security';
import { gemini } from 'agentfootprint/providers';
const ENGINE = process.env.REASONING_ENGINE_ID!;
const PROJECT = process.env.GOOGLE_CLOUD_PROJECT!;
const vertex = { project: PROJECT, location: 'us-central1', reasoningEngine: ENGINE };
await standingAgent({
agentFactory: () =>
Agent.create({
provider: gemini({ project: PROJECT, location: 'us-central1' }),
model: 'gemini',
artifacts: gcsArtifacts({ bucket: 'my-agent-artifacts', projectId: PROJECT }),
credentials: googleIdentity(),
}).build(),
host: nodeHost({ port: Number(process.env.PORT ?? 8080) }),
sessions: agentEngineSessions(vertex),
});
const memories = memoryBankStore(vertex); // wire into defineMemory / defineRAGOne credential path serves all of it: Application Default Credentials. The
same runtime service account that gemini() reads is what gcsArtifacts,
agentEngineSessions and memoryBankStore authenticate with, and what
googleIdentity vends to your tools. On Cloud Run or GKE that is zero
configuration; elsewhere it is gcloud auth application-default login or
GOOGLE_APPLICATION_CREDENTIALS. See Identity
above.
Telemetry — standard OTLP, and no factory
Google's Telemetry API accepts standard OTLP, and Google's own migration guide
recommends moving off the Cloud Trace exporter because its proprietary transform
can lose data. otelObservability() already takes a tracer you built, so pointing
agentfootprint at Cloud Trace is consumer wiring.
Why there is no googleCloudTracer() factory
It was designed and then not built. Such a factory would construct an OTel
TracerProvider from packages you have to install either way
(@opentelemetry/sdk-trace-node, an OTLP exporter, @grpc/grpc-js) and attach a
GoogleAuth header — about twenty lines of wiring behind four new optional peer
dependencies, each of which would need its own pin row and its own version-drift
story. A convenience that restates npm install is not worth the surface.
This recipe was wrong until 2026-08, and it was wrong in five ways at once
An earlier version of this page said "the recipe below is the whole thing; copy it." The 2026-08 field trial copied it, and it did not initialize — then, once it did, it produced six unrelated traces with zero child spans. Every correction below is a thing that trial had to change to get one readable trace into Cloud Trace, and the version numbers are the ones it ran. A recipe that ships unpinned against a mid-major SDK is a recipe with a shelf life; this one now states its shelf.
Pin the versions. An unpinned install is exactly where two of the five failures came from:
npm install @opentelemetry/api@1.9.1 @opentelemetry/sdk-trace-node@2.10.0 \
@opentelemetry/exporter-trace-otlp-grpc@0.221.0 @grpc/grpc-js@1.14.4 \
google-auth-library@9.15.1google-auth-library@11 is the trap: getRequestHeaders() there answers a WHATWG
Headers object, @grpc/grpc-js reads it with Object.keys(), finds nothing,
and your exporter ships unauthenticated — surfacing as
7 PERMISSION_DENIED: Method doesn't allow unregistered callers from
forceFlush(), long after the code that caused it. Google's own current guidance
is 9.15.1, and the recipe below hands the auth client to gRPC rather than
copying headers out of it at all.
import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { credentials } from '@grpc/grpc-js';
import { GoogleAuth } from 'google-auth-library';
import { otelObservability } from 'agentfootprint/observe';
const auth = new GoogleAuth({ scopes: 'https://www.googleapis.com/auth/cloud-platform' });
const client = await auth.getClient();
// 1. OTel 2.x takes processors in the CONSTRUCTOR — `provider.addSpanProcessor` was removed.
const provider = new NodeTracerProvider({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: 'https://telemetry.googleapis.com', // regional: telemetry.<REGION>.rep.googleapis.com
// 2. TLS *and* renewable Google call credentials, combined — not `createSsl()` plus headers.
credentials: credentials.combineChannelCredentials(
credentials.createSsl(),
credentials.createFromGoogleCredential(client),
),
}),
),
],
});
// 3. Register globally, so the whole process shares one active context.
provider.register();
// 4. Do NOT pass `tracer`. Omitted, the strategy resolves the global tracer and its
// nested spans inherit the active context. Passing `provider.getTracer(...)` here
// produced six disconnected trace IDs and zero children.
agent.enable.observability({
strategy: otelObservability({ serviceName: 'my-agent', genAiSpanNames: true }),
});Two environment variables complete it:
OTEL_RESOURCE_ATTRIBUTES=gcp.project_id=<PROJECT_ID>
GOOGLE_CLOUD_QUOTA_PROJECT=<PROJECT_ID>The second is the fifth correction: quota attribution belongs in
GOOGLE_CLOUD_QUOTA_PROJECT, which the auth client reads, not in a manually
duplicated x-goog-user-project exporter header — Google's current migration
guidance says not to set that header, and the trial's working run did not.
And two Google reads only if you opt in — they are what makes its console render the GenAI views rather than plain spans:
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=EVENT_ONLYOur attribute names are the ones Google's console is looking for
otelObservability({ genAiSpanNames: true }) emits the OpenTelemetry GenAI
semantic-convention span names, and Google's agent-observability dashboards are
keyed on those — sessions, turns, token usage, tool latency. (The gen_ai.*
attributes are emitted either way; the flag renames the spans, which is why it
is opt-in: existing dashboards key on the legacy names. The option is
genAiSpanNames — this page called it genAiSemconvNames until 2026-08, a name
that never existed and would have been silently ignored as an unknown property.)
There is no auto-instrumentation for @google/genai on npm, so our provider
hooks are the instrumentation: the events you get are the ones the library
already records, not a second parallel trace.
Verified shape: six spans in one hierarchy — one root, two iterations, two chat calls, one tool call, five of them with a parent — read back from Cloud Trace by trace id. Prompt text and tool-result text were confirmed absent from the serialized spans.
A 404 right after export is not a failed export
Cloud Trace reads are eventually consistent. The trial's first trace returned
404 for about a minute after a successful flush and 200 with all six spans
afterwards. If you write a verification step, retry only 404, bound it
(60 seconds is enough), and fail immediately on every other status — a retry loop
that swallows 403 teaches you nothing.
The port, the composition and delivery axes, and what each sink drops: Observability sinks.
Concurrency & sessions — where the parallelism comes from
From your process, not from the platform. This column has no platform-per-session row today: Agent Runtime is session-oriented, but agentfootprint ships no host adapter for it (see below), so a Google deployment runs on Cloud Run, GKE or a VM — one process serving many sessions. That is the second and third rows of the concurrency table, and you have to pick one:
- Agent pool —
standingAgent({ agentFactory }), one agent per active session inside one process, bounded bymaxActiveSessions. The right default on Cloud Run, where one container serves many callers concurrently.{ agent }(a single shared agent) serializes every session behind one lock, which is correct on a platform that gives each session its own container and wrong here. - Process per worker — a Cloud Run service scaled past one instance, which
forces a shared
SessionLifecycle: two requests in one conversation can land on different instances, and an in-process or on-disk session store will not find the conversation.
sqliteSessions survives a restart on ONE instance with a persistent disk, and
is wrong the moment you scale out. memorySessions is for tests. The store that
survives the second row is agentEngineSessions, below.
Agent Runtime Sessions — agentEngineSessions()
The row above sqliteSessions: many containers, one conversation.
import { standingAgent, nodeHost, agentEngineSessions } from 'agentfootprint/hosting';
const handle = await standingAgent({
agentFactory: () => buildAgent(),
host: nodeHost({ port: Number(process.env.PORT ?? 8080) }),
sessions: agentEngineSessions({
project: 'my-project',
location: 'us-central1', // picks the REGIONAL host as well as the name
reasoningEngine: '1234567890', // sessions are children of one of these
}),
});AgentEngineSessionsOptions also takes userId, ttl and operationTimeoutMs;
the returned AgentEngineSessions adds parent, forget(sessionId) and a final
close() to the two port methods. SESSION_STATE_KEY is the one sessionState
key the envelope is stored under, exported so you can read a conversation out of
the console or a gcloud call without this library.
A reasoning engine must exist even if you deploy no code to it. Sessions are
children of projects/*/locations/*/reasoningEngines/{id}, and there is nowhere
else to put them. Creating one is a control-plane job (gcloud, Terraform, the
console) that this library deliberately does not do.
Name the engine one way, not two. reasoningEngine takes either the bare id
('1234567890') or the full resource name, and the full name carries its own
project and location — so passing it beside a project that disagrees is
refused rather than arbitrated. The trip-wire the 2026-08-14 trial hit: Google
hands back canonical names carrying the numeric project number, while your
config almost certainly holds the textual project ID. Those two strings
disagree byte for byte even when they are the same project, and this library will
not resolve them as equal on a guess — resolving a number to an ID it never
looked up is how one project's conversations end up written into another's. The
refusal names this case and tells you the fix, which is: pass the engine id
alone beside your project and location, or pass the full name Google gave
you and drop project. Both spellings work; mixing them does not. (The same rule
applies to memoryBankStore, which shares this resolver.)
The fit is genuinely good. Session.sessionState is an arbitrary JSON
Struct and a CheckpointEnvelope is arbitrary JSON, so the envelope goes in
whole and comes back whole — no event log to fold and no blob encoding to get
wrong.
State is written by APPENDING AN EVENT — 9.29.0 patched it, and the field trial caught that
9.29.0 persisted a turn with sessions.patch({ updateMask: 'sessionState,ttl' }),
and this page documented that method. The live service refuses it:
HTTP 400 — Can't update the session state for session …, you can only update it
by appending an event.So agentEngineSessions() stored the first turn of a conversation and failed on
every turn after it — the core job of a session store. Every injected-client test
passed, because a double patches whatever it is handed; only a live call could
find this.
9.30.0 changes the verb. persist() now sends sessions.appendEvent with the
envelope in actions.stateDelta — the repair the same trial verified end to end
(append accepted, the following GET returning the new state). Two things follow:
- A state delta merges by top-level key, and this store writes exactly one
(
SESSION_STATE_KEY), so another guest's keys under the same session survive. - A conversation now has an event log behind it — one event per persisted
turn, because that is the only writing surface the service offers. Nothing here
reads it:
hydratestill reads the one envelope out ofsessionState, so the log is the service's audit trail of our writes, never a second copy of the truth.forget()deletes the session and its events together. ttlis sent on create only. Whether an appended event renews the expiry is not something this library has measured, so nothing here claims sliding expiry — set attllong enough at creation if a conversation must outlive it.
If you are upgrading from 9.29.0: no session data is lost, and no migration is needed. Sessions written by 9.29.0 hydrate unchanged; their next turn appends rather than patching.
Five SDK facts shaped the adapter, and two of them would have been bugs.
createaccepts a caller-suppliedsessionId. Your session id is the resource id, sohydrateis onegetby name — no mapping table, no listing.createanddeletereturn a long-running Operation;getandappendEventreturn directly. Every operation-shaped write waits for it to reportdonebefore returning. Apersistthat came back early would make the very nexthydratea race whose failure mode is "no conversation" — which nobody can tell from a new user. PastoperationTimeoutMs(default 30s) it refuses rather than reporting a write it never saw land.Session.userIdis required on create and immutable afterwards. The port handspersistno user, so one is resolved: by default from the conversation's own signed principal (envelopeOwner), so the service's idea of the owner and this library's ownership index agree by construction. A conversation that ran anonymously is stored underDEFAULT_USER_IDrather than a minted per-session id — minting one would make everylistByUserreturn exactly one row and look like it worked.sessionStatecannot be patched at all (the callout above). The first write is the only one that may nameuserId; every later turn appends an event, which touches the state and nothing else.- Every appended event carries an
author, and the service treats it as free text. It defaults toSESSION_EVENT_AUTHOR('agentfootprint'), which is what the console shows as having written the turn — useful beside somebody else's agent writing to the same engine. Override it with theeventAuthoroption. It is not the conversation's owner: that isuserId, pinned at create and immutable. - An append that fails for a reason other than "no such session" is refused,
not turned into a create. The trial proved the two ends of the write path —
createon a new session,appendEventon an existing one — and never measured what the service answers when you append to a session that is not there. So a failed append asks the resource whether it exists rather than branching on a guessed status code, which keeps the first turn of every conversation off an assumption. listByUserbuilds an AIP-160 filter, and a user id is caller data. The id goes in as a quoted literal with the backslash escaped before the quote — in that order, because this grammar honours backslash escapes, so escaping only the quote leaves the escape character free to escape our escape and let the rest of the id out of the literal as filter syntax. A listing that matched every non-empty user id would hand back other people's conversation ids, timestamps and message counts.
`Session.userId` is metadata, not authorization — the door is yours
Field-checked, 2026-08: one project ADC principal read both Alice's and Bob's
sessions directly, by name, without presenting either end user's identity.
userId is immutable ownership metadata that the service records and filters
on; it is not an access-control boundary, and any principal with project
credentials sees every session under the engine.
So binding the authenticated person to the conversation is the host's job, not the
store's — which is exactly what
identity: { verify } does on standingAgent: the
token is verified before the run's scope is composed, and the session-history
wire ops refuse outright on a door with no verifier. Do not let a raw
sessionId off the wire select a conversation.
ttl has a 24-hour floor. It is a knob for keeping conversations longer;
the service rejects anything shorter, so a one-hour session is not available at
any price.
The reading law is inherited, not re-implemented. A session that was never
written hydrates as undefined. A session that is present but unreadable —
bytes that are not an envelope, or a format this runtime does not know — is
refused by name with UnreadableEnvelopeError. Those are different facts and
only one of them is safe to answer with a fresh start.
Keep large payloads out of the session. sessionState holds the whole
envelope, so a run that stuffs a 40 MB report into its conversation puts it in
every hydrate. That is what the claim-check store
is for: gcsArtifacts keeps the bytes in a bucket and the conversation carries a
ref. The two are designed to be used together, and the composed example is in that
section.
Memory Bank — memoryBankStore()
import { memoryBankStore } from 'agentfootprint/memory';
const store = memoryBankStore({
project: 'my-project',
location: 'us-central1',
reasoningEngine: '1234567890',
// Widen the scope so memories outlive one conversation — usually the point:
scopeFor: (id) => ({ tenant: id.tenant ?? '_', principal: id.principal ?? '_' }),
});Memory Bank is a natural-language memory service — not a key-value store and
not a vector database. A Memory is a fact string plus an immutable scope,
and retrieval takes a question in words which Google embeds and ranks on its own
side. Three consequences, each with a silent-failure mode this adapter turns into
something you can see.
The score is a DISTANCE, and smaller is closer
The port's ScoredEntry.score is a cosine similarity, where higher is closer.
The service reports a distance (Euclidean), where smaller is closer — its
own documentation says so, and the 2026-08 field check read finite distances back
from a live bank to confirm the scale. Forwarded unconverted, retrieval returns the least
relevant memories first, with a confident-looking number in the right range that
no threshold and no eyeball can separate from a working search.
So the adapter converts: score = 1 / (1 + distance), which is strictly
decreasing, lands in (0, 1], and therefore gets the ordering right — which
is the whole point. scoreFromDistance is exported so you can see exactly what it
does. The raw distance rides entry.metadata.distance, unmodified.
And minScore is refused by name. That number was calibrated for a cosine
similarity and this scale is not one; applying it anyway would silently keep or
drop the wrong memories. Bound the result with k, or filter on the raw distance
yourself. (The S3 Vectors adapter refuses a non-cosine index for the same reason.
Here the metric is Google's and cannot be changed, so the threshold is what goes
rather than the store.)
`scope` is an exact match and immutable — decide before the first write
A retrieval whose scope is a subset of a memory's scope returns nothing —
not a superset, not a partial match. And Memory.scope cannot be changed after
it is written.
The default is the full identity tuple —
{ tenant, principal, conversation } — which is the isolation every other store
in this library enforces, so agent code behaves identically whichever column it
runs on. It is also the narrowest useful choice: memories written under it are
retrievable only inside that one conversation. If what you want is "remember this
person across their conversations", widen it with scopeFor before you write
anything. Changing the convention on a live bank means re-writing every memory
in it, because the old ones keep the scope they were written with and a retrieval
under the new one finds them with an empty result that looks exactly like "this
person has told us nothing".
MemoryScope is the map type. An empty scope is refused — {} is not "no
scoping", it is a real scope matching every other empty-scoped memory in the bank
whoever wrote it.
It never ranks the vectors you wrote. The store declares
supportsVectorSearch: false and ranksBy: 'server-text', so indexCorpus /
indexFolder / indexDocuments refuse it by name instead of embedding a
whole corpus, billing for it, reporting success and leaving it unreachable
forever. An embedding on an entry handed to put() is not stored. search()
reads options.text and refuses without it — a vector is the one thing this
backend cannot use.
Five operations are refused rather than emulated. putIfVersion, seen,
recordSignature, feedback and getFeedback have no primitive here — a
Memory carries no etag and there is no dedup or feedback surface. The sibling
AgentCore adapter emulates them in a per-process Map; this one does not, and the
difference is deliberate. A store you reach for because it is shared across a
fleet is the worst possible place for per-process shadow state: seen() would
answer "never" in the second container for a signature the first one recorded,
and an emulated putIfVersion would report { applied: true } to two writers at
once. A refusal you read once beats a correctness bug you never find.
A memory is addressed by the scope AND the entry id, never the id alone. The
resource name is <engine>/memories/<scope fingerprint>-<entry id>, and that is
not cosmetic. Entry ids in this library are deliberately deterministic and
identity-free — msg-<turn>-<index>, fact:<key>, snap-<turn> — so two people
talking to the same agent mint the same ids. A resource name addresses a row
directly, so an address without the scope in it would be one row for both of
them: the second writer's fact landing on the first writer's row while the
immutable scope stays the first writer's. That reads back as one tenant seeing
another's private fact, the second tenant's own write invisible to them, and
forget() finding nothing to erase. Keying on the resolved scope rather than
the raw identity keeps the two decisions one decision — a scopeFor widened to
{ tenant, principal } shares one row per person across their conversations,
exactly as far as it widens retrieval.
The address is a partition, not the boundary. The boundary is the stored scope,
re-checked on the way back from every read and before every overwrite — so
put() reads the row it is about to write, and a row carrying somebody else's
scope is refused (MemoryScopeConflictError) rather than written through. That
read is what makes the steady-state put two calls instead of one; it buys a
tenant check that a bare patch cannot give you.
A retrieved memory's `name` is not the name you wrote
Field-checked, 2026-08: create and list preserved caller-chosen memory ids
(alice-orbital-code came back as itself), while similarity retrieval returned
generated numeric resource names for the same facts. If you are writing your own
integration over this API, do not assume a retrieved name round-trips to the key
you wrote — reconcile on the fact and the scope, or keep your own index. (This
adapter addresses rows by scope + entry id and re-checks the stored scope on the
way back, so the instability does not reach the MemoryStore port; it will reach
you the moment you call the API directly.)
Writes wait. create, patch and delete are long-running operations, so
every write blocks until the service says done — same reasoning as the session
store, same operationTimeoutMs.
forget() does not use purge, and that is the load-bearing detail.
PurgeMemoriesRequest.force defaults to false, which the service documents as
"validated but not executed". A GDPR erasure built on it without that flag would
report success and delete nothing — a compliance failure that looks exactly like a
working one. And purge selects rows with a filter string whose ability to express
an exact scope match is unverified: under-match leaves data behind, over-match
deletes somebody else's. So forget() paginates a scoped retrieve and deletes each
memory by name, at one call per memory, which is the right price.
MAX_PAGE_SIZE (100) is the service's own ceiling on a page or a top-k; it
silently coerces anything larger, so this adapter clamps and says so.
MemoryBankStore is the class behind the factory, and MemoryBankStoreOptions
carries scopeFor, ttl, pageSize and operationTimeoutMs.
What an entry keeps — and what 9.29.0 silently dropped
A Memory is a fact string, an immutable scope, and a metadata map of typed
scalars. A MemoryEntry is richer than that, so the mapping is where fidelity is
won or lost — and 9.29.0 lost it. The 2026-08-14 field trial wrote an entry with
source.turn, source.messageId and its own metadata, and read back an entry
with no source at all and only this adapter's generated metadata. The
MemoryEntry contract says the opposite in as many words: MemorySource.identity
is a field "storage adapters MUST preserve verbatim on every read/write", and
those fields drive audit, causal chains, decay and retrieval policy.
9.30.0 carries them. source, your own metadata and decayPolicy are
stored as JSON under prefixed metadata keys of ours and restored verbatim.
Three rules come with that, and all three are visible rather than silent:
- Three metadata keys are generated on every read —
source(the backend name),resourceName(the Vertex name) anddistance(the raw retrieval distance). Your own metadata under one of those names is refused by name (MemoryMetadataConflictError) rather than stored and shadowed. Values this store itself produced are recognised and dropped instead, so reading an entry and writing it straight back is always safe. Recognition is by identity, not shape:resourceNameanddistancecount as this adapter's only when thesourcestamp it writes on every read rides with them on the same entry, so ametadata.resourceName = 'sku-42'of your own is refused rather than accepted as ours. The one case that stays ambiguous is adistanceyou set yourself on an entry that did come out of this store — it is dropped; keep such a value under a name of your own. - A carried field over
MAX_CARRIED_JSON(8,192 JSON characters) is refused, never truncated. That bound is this adapter's own — the service's ceiling on a metadata string is not something this library has measured — and the refusal says so. Provenance that came back shortened would be provenance nothing could tell was shortened. embeddingandembeddingModelare still dropped, and that one is a stated refusal: there is nowhere to put a vector here and nothing that would rank it (supportsVectorSearch: false).
Rows written by 9.29.0 keep working — they simply carry no provenance, and read
back as entries without a source. Nothing needs migrating; re-put() an entry
if you want its provenance stored.
Identity — googleIdentity()
import { googleIdentity } from 'agentfootprint/security';
const agent = Agent.create({ provider, credentials: googleIdentity() })
.tool(defineTool({
name: 'read_sheet',
needs: [{ credential: 'sheets', scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'] }],
execute: async (args, ctx) => fetch(url, { headers: ctx.credential!.toHeaders() }).then((r) => r.text()),
}))
.build();The narrow adapter: it vends Google access tokens for Google APIs, from
whatever credential the environment already has — Application Default Credentials
on Cloud Run or GKE, a workload-identity federation config, a service account,
optionally impersonating another one. CLOUD_PLATFORM_SCOPE is the default scope;
a request's own scopes win over it. GoogleIdentityOptions also takes
impersonate (a GoogleImpersonation: targetPrincipal, delegates,
lifetimeSeconds), services and id. GoogleAuthClientLike is the small
client shape it calls, exposed for a custom integration.
`mode: 'user'` is refused, not served with a machine token
It is not a token vault. The AWS column's identity adapter can vend a GitHub token for a user because a per-user OAuth vault sits behind it; Google's equivalent — the Agent Identity auth manager — is Preview with no Node surface.
So a delegated request is refused by name. Answering it with a machine token
would be the exact silent downgrade the port exists to prevent: the call succeeds,
the data comes back, and it ran with the agent's access rather than the person's,
with nothing downstream able to tell. A userToken on a request is refused for the
same reason rather than quietly ignored.
The one-hour token, and the one place it bites
A Google access token lives about an hour. That is fine wherever the credential is
fetched per use — which is how ctx.credential works, so the ordinary path is
unaffected. This adapter caches the client, never a token, so the library's own
refresh logic keeps it fresh, and expiresAt is reported on every issued
credential.
It bites in exactly one place, and it looks like it should work: filling the
OpenAI-compatible endpoint's apiKey with a token from here. A string is
captured when the provider is constructed, and a long-lived agent process
outlives it — which a field trial measured directly: the same call that returned
an answer on a current token returned 401 on an expired one.
The fix is one character of shape — pass a function instead of a string, and it is re-read before every request:
openai({
baseURL: 'https://…/openapi',
apiKey: async () => (await client.getAccessToken()).token ?? '',
});The boundary is the call, not the chunk: a stream keeps the key it started
with, because nothing can re-authenticate a socket that is already open. And the
simpler answer is still the native gemini() provider, which reads ADC through
the SDK and refreshes underneath you.
Set services when Google is not your only credential. Unset, this provider
answers for any service — the token is a Google credential and you know which
Google API you are about to call. But in a deployment where some tools declare
needs: [{ credential: 'github' }], an unrestricted Google provider would hand a
Google token to the tool that wanted a GitHub one, and the failure would surface
as a puzzling 401 from GitHub rather than as a wiring error here.
Secrets never appear in what it throws. Auth libraries echo file paths and
request detail into failure text, and a message thrown from a CredentialProvider
reaches the LLM as a tool result and rides agentfootprint.credential.failed to
every sink. So the error's NAME and the operation come through and its text does
not, and the original is never attached as cause — that would travel into every
serializer that walks own properties.
Agent Runtime on Node.js — the verified recipe
The contradiction is settled. Google's deploy page says "Agent Runtime deployment only supports Python"; its runtime-contract page says to build your container "in the programming language and server framework of your choice" and ships a Node build script. This page used to say the question was open and gate an adapter on a probe. The probe ran — an independent field trial, 2026-08 — and the answer is:
Current Agent Runtime does support Node.js through the custom-container API and runtime contract. It does not follow that the older Python object/source deployment path accepts Node source; that path is still Python-specific.
So the "only supports Python" sentence describes the SDK packaging path, and is
wrong if you read it as a statement about the custom-container runtime. The trial
created a Reasoning Engine from a Node image, called its unary query and its
managed streamQuery, and read this library's own events back out of Cloud
Logging.
The container contract
Listen on 0.0.0.0:8080 and serve two routes:
| Route | Body in | Body out |
|---|---|---|
POST /api/reasoning_engine | { class_method, input } | { output } |
POST /api/stream_reasoning_engine | the same | NDJSON — one JSON object per line |
The unary route is a httpHost dialect — a wire is exactly the object that
turns one deployment's JSON into the port's vocabulary, so this is configuration
rather than an adapter:
import { httpHost, standingAgent, type HttpWire } from 'agentfootprint/hosting';
const agentRuntimeWire: HttpWire = {
readRequest: ({ body }) => {
const input = body.input as { message?: string; session_id?: string } | undefined;
return { input: String(input?.message ?? ''), sessionId: input?.session_id };
},
health: (uptimeMs) => ({ status: 'ok', uptimeMs }),
output: (output) => ({ output }),
failure: (message, code) => ({ error: { message, ...(code ? { code } : {}) } }),
chunk: (text) => ({ output: text }),
};
const handle = await standingAgent({
agentFactory: () => buildAgent(),
sessions,
host: httpHost({
name: 'agentRuntime',
wire: agentRuntimeWire,
invokePath: '/api/reasoning_engine',
healthPath: '/health',
}),
});No adapter ships, and the reason is the second route
httpHost serves one invoke path, and its streamed replies are Server-Sent
Events. Agent Runtime's second route wants NDJSON at a different path, so a
shipped agentRuntimeHost() would have to grow the host a second route and a
second framing — a change to the port's shape that is worth making once the demand
is real, not to close a docs gap. Until then: the wire above serves
/api/reasoning_engine, and /api/stream_reasoning_engine is a route you write
beside it over agent.stream(...), one JSON.stringify(...) + '\n' per chunk.
The trial's container did exactly that, and its managed streamQuery returned
seven chunks.
The deployment, step by step
1. Build and push the image. The trial used its repository Dockerfile, built
by Cloud Build into Artifact Registry as a side effect of a Cloud Run source
deploy. Any push to Artifact Registry works; what matters downstream is the
digest.
2. Grant the managed service agent read access to your repository — do this first. Skipping it is the single most expensive mistake on this path, because the failure is mute:
gcloud artifacts repositories add-iam-policy-binding cloud-run-source-deploy \
--project=<PROJECT_ID> --location=us-central1 \
--member='serviceAccount:service-<PROJECT_NUMBER>@gcp-sa-aiplatform-re.iam.gserviceaccount.com' \
--role='roles/artifactregistry.reader'3. Create the Reasoning Engine — POST to the regional host:
POST https://us-central1-aiplatform.googleapis.com/v1/projects/<PROJECT_ID>/locations/us-central1/reasoningEngines{
"displayName": "my-node-agent",
"spec": {
"containerSpec": {
"imageUri": "us-central1-docker.pkg.dev/<PROJECT_ID>/<REPO>/<IMAGE>@sha256:<DIGEST>",
"port": 8080
},
"deploymentSpec": {
"resourceLimits": { "cpu": "1", "memory": "1Gi" },
"minInstances": 0,
"maxInstances": 1,
"containerConcurrency": 3
},
"classMethods": [
{ "name": "query", "api_mode": "", "parameters": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] } },
{ "name": "stream_query", "api_mode": "stream", "parameters": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] } }
],
"agentFramework": "custom"
}
}classMethods is what the managed API exposes: api_mode: "" is the unary
:query, api_mode: "stream" is :streamQuery. agentFramework: "custom" is
the any-language door.
4. Call it. POST .../reasoningEngines/<ID>:query runs the unary route;
POST .../reasoningEngines/<ID>:streamQuery runs the streaming one. The trial's
unary call completed a real two-call tool loop and returned the tool's answer;
the streamed call returned seven chunks totalling 660 characters.
5. Read the logs. Agent Runtime logs under resource type
aiplatform.googleapis.com/ReasoningEngine — not cloud_run_revision. Console
JSON from this library's observability sink arrives as queryable jsonPayload,
so both LLM calls, agentfootprint.stream.tool_end and the final
agentfootprint.agent.turn_end are one filter away.
What failed on the way, and why it will fail for you too
- A reserved environment variable rejects the whole request.
spec.deploymentSpec.envcontainingGOOGLE_CLOUD_PROJECTreturns400 FAILED_PRECONDITION: "Environment variable name 'GOOGLE_CLOUD_PROJECT' is reserved." The runtime supplies its own project context. Remove every reserved Google variable; pass your own under your own names. - No Artifact Registry permission looks like a broken container. Before step 2
was applied, the accepted deployment terminated with a generic
"failed to start and cannot serve traffic", produced no container
stdout/stderr at all, and rolled the resource back — so
reasoningEngines.getanswered404and there was nothing left to inspect. The identical request succeeded once the service agent could read the image. If your first deploy dies with no logs, suspect the pull before you suspect your code.
The ordinary path is still Cloud Run
Nothing above is required to run this library on Google Cloud. The trial also
deployed the same image to a private Cloud Run service — dedicated runtime
service account with roles/aiplatform.user, --no-allow-unauthenticated,
--min=0 --max=1 — and ran the same tool loop and the same incremental stream
across the network (10 NDJSON chunk events over 11 network reads, first chunk at
926 ms). Cloud Run needs nothing new from this library:
import { standingAgent, nodeHost, agentEngineSessions } from 'agentfootprint/hosting';
const handle = await standingAgent({
agentFactory: () => buildAgent(), // one agent per active session
host: nodeHost({ port: Number(process.env.PORT ?? 8080) }),
sessions: agentEngineSessions({ // shared across instances
project: process.env.GOOGLE_CLOUD_PROJECT!,
location: 'us-central1',
reasoningEngine: process.env.REASONING_ENGINE_ID!,
}),
});Swap agentEngineSessions for sqliteSessions({ file: '/data/sessions.db' }) if
you are pinned to one instance with a persistent disk and would rather not create
a reasoning engine — it is one argument, which is the entire point of the port.
A stream that delivers one chunk is usually a thinking budget, not a broken stream
The trial's first streaming run over Cloud Run delivered exactly one chunk and
stopped with stopReason: 'max_tokens'. The usage said why:
{ input: 21, output: 9, thinking: 243 } — a 256-token budget, 243 of which the
model spent thinking, leaving nine tokens of visible answer. Nothing was wrong
with the stream. For a visible-output test set thinking: { budget: 0 } and give
the answer real room; the same test then delivered ten chunks. usage.thinking is
the number that tells you this, and gemini() reports it as its own
field — which is one of the concrete reasons to prefer it over the
OpenAI-compatible endpoint, where that count does not exist.
Agent Gateway — the MCP transport works; Google's identity path does not
Two different claims, and this page used to make only the flattering one.
What is verified. gatewayTransport() + mcpClient() against an ordinary
bearer-token gateway: the 2026-08 trial served a real agentfootprint tool over
Streamable HTTP MCP, discovered it through the transport, called it, and round-
tripped the result. Five HTTP requests carried five different freshly vended
credentials, and none was retained on the transport — the token-vending design
doing exactly what it claims.
What is refuted. Google's current Agent Gateway documentation states that
agent identities use mTLS and DPoP. GatewayTransportOptions carries a URL, a
CredentialProvider, service, scopes, mode and static headers — it can vend
headers. It cannot present a client certificate, sign a DPoP proof, or hand you
a fetch to do either. So the earlier "should work unchanged" line was wrong for
the identity-enforced Google path, and the "whether the gateway demands a client
certificate is unverified" hedge has an answer now: it does.
The seam if you need it today. The generic http MCP transport does accept a
custom fetch, which is where a Google-specific signer belongs — you build the
mTLS agent and the DPoP proof, this library carries the MCP session over it. That
is bring-your-own, and it is offered as a seam rather than described as support.
See Tools & gateways.
Governance — enforced at the Gateway, not here
There is nothing to attach, and that is a finding rather than a gap. Agent Gateway terminates and routes traffic, parses MCP requests to extract tool attributes for authorization, and layers Model Armor and Semantic Governance in front of your process; access to anything not in the Agent Registry is denied by default. A denial therefore arrives as an MCP error on the tool call, lands in the loop as that tool's result, and the model reads it and adapts.
This is architecturally identical to AgentCore, where agentCorePolicy was retired
in 9.4.0 for dispatching an operation that does not exist. No SecurityStrategy
adapter will be built for Google either. For rules you own, use
PermissionPolicy.fromRoles(...) or the .toolMiddleware() chain —
Governance & policy.
What is NOT here
Absences are facts, so here they are stated rather than discovered:
- No
googleCloudTracer()factory — a docs recipe instead, argued above. - No Agent Runtime host adapter. Not because the question is open — the
Node-deployability probe answered it —
but because the contract's second route is NDJSON at its own path, which
httpHostdoes not serve. The unary route is awiretoday; the recipe is above. - No mTLS or DPoP on
gatewayTransport, which is what Google's Agent Gateway requires of an agent identity. Header vending only; the generichttptransport's customfetchis the seam. - No thought signature on a plain TEXT answer. The function-call signature — the one whose absence broke a current-model tool loop in the field — is carried back byte-for-byte. A signature attached to a text part with no function call is not: the port's assistant turn is a string and has nowhere to keep one. Named, rather than half-handled quietly.
- No user-delegated credential.
googleIdentityvends the deployment's own identity and refusesmode: 'user'by name — Google's per-user token vault is Preview with no Node surface, and a machine token in its place would be a silent downgrade. - No compare-and-set, dedup or feedback on Memory Bank —
putIfVersion,seen,recordSignature,feedbackandgetFeedbackare refused by name rather than emulated in a per-process Map that a second container cannot see. - No
memories.purge, and no reading of the session event log.purgeis deliberately not called (itsforceflag defaults to false — a forget built on it would report success and delete nothing).sessions.appendEventis now called, because it is the only way the service lets state be written;sessions.events.listis not, becausehydratereads the one envelope out ofsessionStateand a second copy of a conversation is not truth. - No control plane. Creating a
reasoningEngine, a memory bank, an index or a gateway isgoogleapis/ gcloud / Terraform. agentfootprint is runtime-only. - No multi-modal input, no thought summaries, and no grounding tools (Google
Search, code execution, URL context,
mcpToTool) ongemini()— text in, text and function calls out. - No
@google-cloud/vertexai, ever. It is past its own removal date. - No Agent Retrieval and no Agent Identity auth manager, both parked with dates above. They are re-checked before any release that widens this column.
- No pricing table for Gemini models. Cost reporting uses the token counts Google returns; the per-token rates are yours to configure.
What these adapters cost to run
No invented numbers — the shapes, so you can price them against your own tariff:
- Sessions: one
appendEventper turn in the steady state (on the first turn of a conversation, one failed append plus onegetplus acreateand its operation wait), onegetper hydrate, onelistper history listing. Storage is whateversessionStateholds, plus one small event per persisted turn. - Memory Bank: one
getplus onepatch(orcreate+ wait) perput— thegetis the tenant check described above, not an optimisation — oneretrievepersearchor perlistpage, and one delete per memory onforget— erasure is the expensive call here, by choice. - Identity: no billed API call of its own. Token minting is part of the auth library's own refresh, and impersonation adds one IAM token exchange per client.
- Egress: all three are regional. Running your process in the same region as the reasoning engine keeps these calls off the cross-region path.
The billing boundary
The single most surprising thing the 2026-08 trial hit was not an API — it was an invoice line that does not exist where everyone assumes it does.
Your Google Cloud billing account does not pay for the Gemini API
The two Gemini doors sit on two separate billing systems. Vertex bills to the Cloud billing account linked to your project. The Gemini API (AI Studio) bills to its own prepayment balance, managed in AI Studio.
The trial's project had billing enabled and an unspent $300 introductory Cloud
credit. A valid API key, correctly restricted to
generativelanguage.googleapis.com, still answered
429 RESOURCE_EXHAUSTED — "Your prepayment credits are depleted." Nothing was
misconfigured. The credit simply does not reach that door.
So a 429 on gemini({ apiKey }) usually means "add prepayment in AI Studio",
not "slow down" — and no retry policy will ever clear it. If you are on Cloud
already, the Vertex door removes the whole question.
| What you use | Who bills it | What "out of money" looks like |
|---|---|---|
gemini() / geminiEmbedder() with { project, location } | Cloud billing account on the project | ordinary Cloud billing failures / quota errors |
gemini() / geminiEmbedder() with { apiKey } | AI Studio prepayment, separate | 429 RESOURCE_EXHAUSTED, prepayment credits depleted — even with Cloud credit unspent |
| The OpenAI-compatible endpoint | Cloud billing (it is Vertex underneath) | plus a 401 the hour the token expires, which is not a billing failure and looks like one |
| Cloud Run / Agent Runtime / Cloud Storage / Cloud Trace / Artifact Registry / Cloud Build | Cloud billing account | ordinary Cloud billing |
What a full validation actually costs. The trial's own estimate for everything on this page — Cloud Run, an Agent Runtime deployment, container builds, live Gemini calls, embeddings, a GCS bucket, Memory Bank, native sessions and traces — was under $0.25, probably much less. That is deliberately conservative and explicitly not a claimed invoice: billing export was not configured and console totals lag. The shapes behind it:
- ~95 build-seconds across three container builds.
- Cloud Run at 1 vCPU / 512 MiB,
min=0 max=1, a handful of short requests. - Agent Runtime at 1 vCPU / 1 GiB,
minInstances=0, alive about three minutes, two requests, then deleted. - Models: a few short
gemini-2.5-flashtool/stream/auth calls, plus four 768-dimensional embeddings (three documents and one query — a re-index of unchanged files embedded zero, which is the RAG layer's incremental skip paying for itself). - Storage: one small image for under an hour; one bucket with seven small objects, deleted the same day.
- Failed calls cost nothing at the model. The 404, the 429 and the precondition rejections produced no model output.
Three resources that keep costing after you stop paying attention, all of
which the trial deleted explicitly: a Reasoning Engine (it exists whether or not
it serves traffic), Artifact Registry images, and any bucket the deploy created
for source archives. And one that cannot be undone rather than billed:
creating the (default) Firestore database permanently fixes your project's
Firestore location. Decide the region before the first databases create, not
after.
Status
| Piece | Door | Status |
|---|---|---|
gemini() / GeminiProvider | agentfootprint/providers | Field-validated on the Vertex door — an independent field trial, 2026-08 (gemini-2.5-flash: tool loop, streaming, usage). Gemini-API door and current-generation tool calls: see the matrix |
geminiEmbedder() | agentfootprint/providers | Field-validated on the Vertex door — 2026-08, gemini-embedding-001 at 768 dimensions through defineRAG |
gcsArtifacts() — the artifact store on Cloud Storage | agentfootprint | Field-validated — 2026-08, nine live checks against a real bucket |
agentEngineSessions() — conversations in Agent Runtime Sessions | agentfootprint/hosting | field-validated 2026-08-14 (create, hydrate, listing, owners, forget). The write verb is field-corrected in 9.30.0: state is appended as an event, because the service refuses a sessionState patch |
memoryBankStore() / MemoryBankStore — the MemoryStore port over Memory Bank | agentfootprint/memory | field-validated on the data plane 2026-08-14. Entry fidelity is field-corrected in 9.30.0: source, caller metadata and decayPolicy are carried instead of dropped |
googleIdentity() — Google access tokens from ADC / WIF / impersonation | agentfootprint/security | field-validated 2026-08-14 — a real bearer authorized a Vertex call, both fail-closed refusals held, and the credential does not serialize its token. Expiry-triggered refresh is still unproven for this door |
| The Google surface pin (dispatch · method-name reality · API-version reality · completeness) | — | 9.13.0 — runs in CI against the really-installed packages, now including @googleapis/aiplatform |
ContextWindowExceededError from Gemini's over-long-request sentence | agentfootprint | 9.13.0 — contract-shaped and tested; awaiting field use |
Cloud Trace / Cloud Logging via otelObservability() + OTLP | agentfootprint/observe | Field-validated as the corrected recipe — 2026-08, one six-span trace read back. The pre-trial recipe on this page did not work |
Agent Gateway via gatewayTransport + mcpClient | agentfootprint/providers | Field-validated for an ordinary bearer gateway (2026-08). Incompatible with Google's identity-enforced path — that path is mTLS + DPoP, and this transport vends headers only |
| Agent Runtime hosting | — | No adapter — but Node deploys, verified (2026-08). Recipe above |
| Agent Retrieval · Agent Identity auth manager | — | Parked 2026-08-12 — no Node surface |
SecurityStrategy for Model Armor / Semantic Governance | — | Will not be built — gateway-enforced, no data-plane call |
Other providers
Google Cloud is one column, not the pattern. Every port on this page is filled by
something else somewhere else and the agent code does not change between them:
AWS & Bedrock AgentCore is the same map for AgentCore's managed
primitives, and On-premises & self-hosted is the same map
for a deployment that owns its own machines — a local model instead of Gemini,
sqliteVectorStore instead of Vector Search, fileObservability instead of Cloud
Trace.
The three adapters this column gained are the clearest illustration of what the
ports buy you: agentEngineSessions ↔ agentCoreSessions ↔ sqliteSessions,
memoryBankStore ↔ AgentCoreStore ↔ pgVectorStore, googleIdentity ↔
agentCoreIdentity ↔ vaultCredentials. Each trio is one argument to
standingAgent, defineRAG or Agent.create. The agent code does not change.
Next steps
- Gemini — the provider in full: the mapping table, the two doors, the honest limits.
- Embedders —
geminiEmbedderbeside the other four, and the input-ceiling machinery. - Infrastructure: ports & adapters — the pattern this page is one instance of.
- Hosting & runtime — the concurrency table and the
SessionLifecycleport. - Observability sinks — the port the OTLP recipe feeds.
