Google Cloud & Gemini
A provider column. What agentfootprint adapts on Google Cloud today — the native Gemini provider, Gemini embeddings, Cloud Storage artifacts, Agent Runtime sessions, Firestore 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, plus a custom fetch seam (9.32.0) | 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 library implements neither. Since 9.32 you can supply your own signer through fetch and keep the rotation — bring-your-own, never support — 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 |
| Firestore (session store) | firestoreSessions() | agentfootprint/hosting | @google-cloud/firestore | collection().doc(), doc.get / .delete, runTransaction (one read, then one set), and a server-side listing built from where + orderBy + startAfter + limit. Not a control plane — the database, the collection, the one composite index and the TTL policy are yours to create | Field-validated except the ownership refusal (2026-08). An independent trial exercised seven of its eight areas against a real Firestore; the eighth, refusing a foreign signer, was added afterwards and is held by tests only. Its eighteen pinned SDK members were hand-verified against a real @google-cloud/firestore 9.0.0 install outside the repo, and the reality assertion skips in CI — the full account is below | 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.
One adapter sits on that rung with one leg of it missing, and says so at
length rather than borrowing the phrase whole: firestoreSessions is pinned and
dispatch-checked like the rest, but @google-cloud/firestore is deliberately not
installed in this repository, so the pin's reality assertion skips in CI. Its
own section explains what that costs
and why the trade was taken.
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 — with one measured exception. 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, and the missing-peer-dep refusals are proved by stubbing
module resolution instead. The exception is
@google-cloud/firestore, which is not installed here: it depends on@opentelemetry/api, so installing it would hoist that package to the repository root and disarm the test provingotelObservability()refuses by name when it is absent. That is the same trade the AWS pin makes, taken for the same reason — so thefirestoreSessionsrow's reality and version assertions skip in CI and run in full for anyone who installs the package locally, while its dispatch assertion runs everywhere. - 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.
The optional peer carries five transitive advisories — and what we will not do about them
@google-cloud/storage is an optional peer, so it is your dependency, not
this library's: nothing here loads it unless you call gcsArtifacts. But you
inherit its tree when you do, and two independent audits (2026-08-13 and again
2026-08-14) reported the same five moderate advisories, reproduced here:
@google-cloud/storage → retry-request → teeny-request → gaxios → uuidThe root is uuid (GHSA-w5hq-g745-h8pq, a missing buffer bounds check in
v3/v5/v6 when buf is provided), reached through gaxios, teeny-request and
retry-request. This is not a defect in agentfootprint, and there is no line
of code here that would fix it — the chain is entirely inside Google's client.
npm audit fix --force offers a resolution and it is the wrong one: it installs
@google-cloud/storage@5.18.3, a major downgrade to a client from a
different era of the API. Trading a bounds check in a code path this adapter does
not exercise for a client several majors behind the service is not a security
improvement, it is a different outage. Do not take it, and this library will
not pin you to it.
What to do instead: pin @google-cloud/storage yourself at the newest 7.x, watch
the upstream chain, and read the advisory in the context of what the adapter
actually calls. For completeness, an audit of this repo also shows five high
advisories (postcss, js-yaml, nanoid, linkify-it, brace-expansion,
hono) — every one of them is dev tooling, none ships in the published package,
and none is on any runtime path. The trial found no secrets in either round.
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. Two stores
survive the second row, and they are one argument apart: agentEngineSessions,
below, if you already own a reasoning engine — and firestoreSessions if you do
not, which is most deployments. Both are SessionLifecycle; nothing above them
moves when you swap one for the other.
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.
Firestore sessions — firestoreSessions()
The same many containers, one conversation row as agentEngineSessions,
without the reasoning engine: one Firestore document per session, in a project
you very likely already have. No instance to size, no connection pool to tune,
and a free tier — the plainest fleet-shared session store on this column.
import { standingAgent, nodeHost, firestoreSessions } from 'agentfootprint/hosting';
const sessions = firestoreSessions({ project: 'my-project' });
const handle = await standingAgent({
agentFactory: () => buildAgent(),
host: nodeHost({ port: Number(process.env.PORT ?? 8080) }),
sessions,
});
// close() is ASYNC on this store, unlike the others. See "the surface", below.
process.on('SIGTERM', () => void handle.close().then(() => sessions.close()));Status: field-validated except the ownership refusal (2026-08)
An independent field trial ran this adapter against a real Firestore and
exercised seven of its eight areas live: the round trip, the server-side
indexed listing with its document-name tiebreak and real cursor, the
missing-index refusal, the hashed document name, the size ceiling, ownership
derived from the envelope, and forget.
The eighth is named because under-claiming and over-claiming are the same
defect. Refusing a turn signed by somebody else — the conflict persist
raises out of its transaction — was not exercised, because it did not exist
when the trial ran. It is held by tests here, including the contested-write case
of the shared battery, and by nothing in the field. Two
writers racing for one fresh session against a real service, with the client's
transaction retry underneath, is precisely the shape a double models by
assumption.
These pages said "NOT field-validated — nothing here has been run against a live Firestore" for two releases after that stopped being true. Under-claiming is the safe direction and it is the same defect class the trials keep reporting: a status that does not track the evidence is a status nobody can use, whichever way it is wrong.
Two caveats stand unchanged. Every SDK member this adapter calls was read off a real
@google-cloud/firestore 9.0.0 install in a scratch project outside this
repository and hand-verified there — eighteen of them, recorded as the
firestoreSessions row of the Google surface pin.
But the package is deliberately not installed here: it depends on
@opentelemetry/api, so installing it hoists that package to the repository root
and disarms the test proving otelObservability() refuses by name when
@opentelemetry/api is absent. An installed package would make one check
runnable and an absent-peer check vacuous, and the absent-peer check is the one
guarding a refusal a reader actually meets.
The consequence, stated plainly rather than left in a footnote: the reality
assertion — "every pinned member really exists on the real package" — SKIPS in
CI. What CI machine-checks is the dispatch pin: this adapter calls exactly
the members its row names and no others, every run, everywhere. That those names
are spelled the way Google spells them is held by a hand check against a real
install, not by a test that runs here. Install @google-cloud/firestore locally
and the reality assertion runs in full.
What the EARLIER trial of a DIFFERENT adapter did and did not establish
The design was informed, before any of that, by a separate and earlier trial, 2026-08-13, of a different, hand-written Firestore session adapter. That one ran against a real Firestore and passed eight ownership and history checks, which is genuine evidence that the ownership and history semantics survive a real service — the reason those laws are inherited here rather than re-invented.
It is not evidence about this code. That adapter had no indexed query, no cursor, no transaction and no index requirement: it read every document for one owner, sorted them in the client and applied an offset cursor — the defect its own report named, and the one shape this adapter exists not to reproduce. Every property described below is a property that trial never exercised.
The peer dependency is optional, and its absence is a refusal rather than a
fallback. @google-cloud/firestore is loaded only when you actually construct
one of these stores, so a deployment that never calls firestoreSessions
installs nothing and importing agentfootprint/hosting never reaches for it.
Without it, construction refuses by name with the install line, and says in the
same breath that memorySessions() and sqliteSessions() need nothing
installed:
npm install @google-cloud/firestoreOne collection, one document per session, seven fields — sessionId,
format, savedAt, envelope, owner, messageCount, expiresAt. That is deliberately
the sqliteSessions table moved: two stores under the same port and the same
laws, so a reader who has audited one does not have to learn a second model to
audit the other. The envelope rides as a JSON string rather than a nested
map, because a conversation carries field names a model's tool call chose, values
that may be undefined, and arrays directly inside arrays — and Firestore
refuses all three. Serialising once at the edge makes every one of those a
non-event; the price is that you cannot query inside a conversation, which no
caller of this port has ever asked to do.
DEFAULT_SESSION_COLLECTION is that collection's name when you choose none:
'agentfootprint_sessions'. Override it with the collection option, which
takes a top-level collection name, not a path — a / in it is refused at
construction naming the option you set, rather than reaching the client as a
complaint about "an odd number of components". EXPIRES_AT_FIELD is exported
beside it, for the same reason: it is the field name an operator types into a
console or a gcloud line — 'expiresAt' — and a name that lives only inside a
string literal is a name somebody mistypes at 2am. See
retention.
listByUser asks the server for the filter, the order and the page. One
query does all of it — an equality on owner, ordered by savedAt descending
with the document name as an explicit tiebreak, positioned by a real Firestore
cursor, and asking for one row more than you wanted. This is the query the
adapter builds against the Firestore client, not something you write; you call
sessions.listByUser(userId, { limit, cursor }) and get a page back:
collection
.where('owner', '==', userId)
.orderBy('savedAt', 'desc')
.orderBy(FieldPath.documentId(), 'desc') // __name__, the document name
.startAfter(lastSavedAt, lastDocumentName)
.limit(limit + 1);Each of those four is load-bearing. Server-side is the whole point: the
alternative — read every document for one owner, sort them in the client, skip N
— is correct right up until somebody has had a lot of conversations, and then it
reads all of them to show ten. The __name__ ordering is explicit rather
than left to Firestore's implicit tiebreak, for the same reason the SQLite
listing orders by session_id after saved_at: two conversations saved in the
same millisecond need a total order, or a cursor placed between them skips one or
repeats one. The cursor is a real startAfter, carrying the previous page's
last stored savedAt and document name, not an offset — an offset re-reads
everything it skips. The extra row makes "is there another page?" a fact
rather than a guess from a full page.
A cursor this store did not mint — truncated, hand-edited, kept across a release,
or minted by sqliteSessions, whose ids may legitimately be path-shaped —
restarts at the top rather than throwing. Page one twice is a cost nobody
notices; a refused listing is an empty sidebar.
The composite index is required, and it is the first thing you will hit
Firestore's automatic single-field indexes do not serve an equality filter on
one field ordered by another. Until the composite index exists, the query above
fails with gRPC status 9, FAILED_PRECONDITION — and this adapter turns that
into FirestoreIndexMissingError (code: 'ERR_FIRESTORE_INDEX_MISSING'), which
carries the collection and database as fields and prints the fix with your
values already filled in:
gcloud firestore indexes composite create \
--collection-group=agentfootprint_sessions \
--field-config=field-path=owner,order=ascending \
--field-config=field-path=savedAt,order=descending \
--database='(default)'That is owner Ascending, savedAt Descending, __name__ Descending. You only
name the first two: an index's trailing __name__ takes the direction of the
last ordered field, so a console-generated index for owner ASC, savedAt DESC is
already the right one.
--database is always printed, including for (default), where gcloud would
have assumed it anyway. A project can hold several Firestore databases, and an
operator on a named one who follows a command without that flag creates the index
somewhere else and gets the identical failure back, with nothing to suggest why.
A refusal that teaches the wrong fix is worse than a bare failure, and one
always-present flag costs nothing to be right. It is single-quoted for the same
kind of reason: the default database is literally spelled (default), and bare
parentheses are a syntax error in every shell you would paste this into.
When you passed your own client, this store cannot know which database it was built for and does not guess — the flag comes back as a placeholder to fill in from wherever the client was constructed, and the message says that an index created on the wrong database leaves the error exactly as it is.
Google's own error carries a one-click console link and is deliberately not echoed: it restates the failing query, and the failing query contains a user id. Look in Cloud Logging for the original.
Ownership is derived from the stored envelope and established ONCE — which is
why the write is a transaction. persist takes no owner and gains none; the
owner is read off the conversation's own signed principal, exactly as
the port requires. The first turn that signs for a
conversation owns it: a later turn carrying a leaner identity must not erase it
(the session would drop out of its owner's list), and a later turn carrying a
different one must not take it (ownership would transfer by writing, which undoes
every check made against the index one turn later).
SQLite states that rule as owner = COALESCE(sessions.owner, excluded.owner).
Firestore has no COALESCE, and set({ merge: true }) is not a stand-in for
one. Merge means "keep the fields I did not mention", so mentioning owner at
all lets the last writer win, and not mentioning it means a conversation that
gains an identity on turn two never records one. Both are the bug rather than a
workaround for it. The rule has to read the stored owner and then decide, and
read-then-decide-then-write is only safe inside a transaction — Firestore retries
the whole function when a concurrent write touched the document, so two
containers persisting the same turn cannot interleave into a lost owner. Every
other field is last-write-wins, through a full set rather than a merge, so a
field written by an older version of this store cannot linger beside the fields
that replaced it.
ownerOf answers undefined for two different facts, deliberately. "No such
session" and "a session nobody ever signed for" come back identically. A store
that told them apart would hand a caller an oracle for which session ids are
real, and the composer's single not-found rests on exactly this ambiguity.
Document names are a full SHA-256 — and that is ADDRESSING, not encryption
A sessionId in this library is opaque: a UUID, an upstream gateway's
correlation id, a path-shaped tenant key, a unicode string somebody typed.
Firestore document names have rules — no /, not . or .., not matching
__…__, at most 1500 bytes — and an id that broke one of them would fail at the
wire on the one turn it mattered, or worse, two ids differing only past a
truncation point would silently become one conversation. So the document name is
sha256(domain + NUL + sessionId) in hex: 64 characters, always legal, injective
for every input anyone will ever have. The raw id is stored in the sessionId
field beside it.
It is not encryption, and it is important not to read it as any. The
conversation is stored in the clear, the sessionId field spells out the id the
hash was made from, and anyone who can read the collection can read every
conversation in it. Encryption at rest is Google's (always on, CMEK-configurable)
and access control is IAM's; neither is implied by the hash. Two things it does
cost you, worth knowing before an incident rather than during one: you cannot
look a session up in the Firestore console by typing its raw id — query
sessionId == '…', or hash it yourself — and a document name carries nothing a
human can sort or scan by. The store exposes the mapping as
sessions.documentIdFor(sessionId) for exactly that moment; it is a pure
function, it touches nothing, and it still answers on a closed store.
The ceiling has a name, and the refusal happens before the write. A Firestore
document is capped at FIRESTORE_MAX_DOCUMENT_BYTES — 1,048,576, the service's
number and not this library's. This store refuses above
FIRESTORE_MAX_ENVELOPE_BYTES, which sits 8 KiB below it because the document
also carries five other fields, their names, and its own path, all of which
count toward Firestore's total. A conversation whose serialized envelope exceeds
that raises EnvelopeTooLargeError (code: 'ERR_ENVELOPE_TOO_LARGE', carrying
sessionId and bytes) before anything is sent, rather than arriving as an
opaque INVALID_ARGUMENT from the wire. Nothing is ever truncated: half a
conversation stored as if it were whole is the failure this store's other laws
exist to prevent. The refusal names the three ways out — compact the conversation
before persisting it (an agent built with .compaction({...})
folds its own history and never reaches this), keep large payloads in the
artifact store with the
conversation carrying only a ref, or use a store with no per-record ceiling.
The reading law is inherited, not re-implemented. A session that was never
written hydrates as undefined — and only that one. A document that exists but
whose payload is not text, or is not JSON, or is not an envelope at all, is
refused by name with UnreadableEnvelopeError; one whose envelope carries a
format this runtime does not know is refused too, naming the formats it does
read. A conversation that exists and cannot be read must never be answered with a
fresh start, because from the outside that is indistinguishable from a brand-new
user. The same check runs on the way in, so a document this store could not
read back is one it never writes.
Retention: a native TTL policy on expiresAt
Firestore deletes documents on its own, on a TTL policy an operator creates
once against a field of type Timestamp. Until 9.42.0 this collection had no
such field — savedAt is a number — so an operator could not point a policy at
it at all without a library change. These pages promised a TTL policy that
9.33.0 did not ship. This is what actually ships.
Two halves, and neither is silent about the other. The store writes the field; you create the policy.
const sessions = firestoreSessions({
project: 'my-project',
expireAfterMs: 30 * 24 * 60 * 60 * 1000, // 30 days after the LAST turn
});
// Then, once, as an operator — the store prints the exact command:
console.log(sessions.retention().enableWith);gcloud firestore fields ttls update expiresAt \
--collection-group=agentfootprint_sessions \
--enable-ttl \
--database='(default)'Or in the console: Firestore → Time-to-live (TTL) → Create policy, on your
collection, field expiresAt. --database is always printed, including for
(default) and single-quoted, for the same reason the index
command
prints it: a policy created on the wrong database leaves conversations living
forever with nothing to suggest why. Pass your own client and the store does not
guess the database — the flag comes back as a placeholder to fill in.
With no expireAfterMs, expiresAt is written as null, a TTL policy ignores
it, and nothing about your deployment changes. retention() reports active: false so "there is a TTL field" and "conversations are expiring" cannot be
confused — which is the question an auditor asks.
The clock is the conversation's own savedAt, not the process's. So the
expiry is idle time: every turn pushes it out, and a conversation dies
expireAfterMs after somebody last spoke. It also makes the stamp a pure
function of the envelope — the same conversation stamps the same instant on any
machine, whatever its clock says.
Why expiresAt is a SECOND field, and what that means for documents you already have
savedAt was not converted to a Timestamp, and that is the load-bearing
decision. It is the ordering key of the composite index, the field the listing
sorts by, and the first half of every cursor this store has ever minted
(<savedAt>:<docId>). Converting it would mean a new index and a new cursor
grammar — but the decisive reason is quieter: Firestore orders values by TYPE
before value. Every number sorts before every timestamp. A collection holding
old number-savedAt documents beside new timestamp-savedAt ones would list
them in two blocks, all of one type then all of the other, with somebody's
conversations silently ordered by the type of a field rather than by when they
were written.
So the migration consequence, plainly:
- Every document already stored stays readable and listable. Nothing about
savedAt, the index, the cursor or the query changed. A document written by 9.33–9.41 is read by this release unchanged, and a test pins exactly that. - Old documents do not start expiring. They carry no
expiresAt, and a TTL policy ignores a document whose field is missing. A conversation still being used gains one on its next turn; a conversation nobody writes to again keeps living. If those matter, delete them once by hand — a query onsavedAtplusforget, or agcloudbulk delete. This adapter will not walk your collection behind your back. - The policy deletes within 24 hours of the instant in the field. That is
the service's own promise, not a hard deadline, and it is repeated in
enableWithrather than left for somebody to discover.
The surface. FirestoreSessionsOptions is
{ project?, database?, collection?, firestore?, expireAfterMs? }. project and database
default to the ambient environment and '(default)'; both are worth setting
deliberately, because "conversations went to the wrong database" looks exactly
like "conversations were lost". firestore takes a client you already built —
most applications that reach for this adapter have one, and two clients in one
process means two sets of gRPC channels for nothing. Passing it together with
project or database is refused rather than silently ignored, because those
settings belong to whoever constructed the client. It does not remove the need
for the package to be installed: FieldPath.documentId() is a static on the
module and there is no string spelling of __name__ the client accepts in
orderBy.
What comes back is a FirestoreSessions — the port, plus the things a real store
owns beyond it:
hydrate / persist | the SessionLifecycle port, unchanged |
listByUser / ownerOf | the optional ownership index, described above |
collection | the collection these sessions live in — useful in an incident |
documentIdFor(sessionId) | the document name one session id maps to |
forget(sessionId) | drop one session; forgetting nothing is not an error |
retention() | the port's optional member, narrowed here to the one arm this store can be: { deletedBy: 'the-backend', active, expiresOn: 'expiresAt', enableWith }. Answers on a closed store, because it reads nothing and calls nothing |
close() | async, idempotent and final |
close() being async is the one shape that differs from the other stores, and it
is not inconsistency for its own sake: terminating a Firestore client closes gRPC
channels, and a Node process holding an open channel does not exit — a void
return would be a promise this adapter could not keep. It is final: reading
or writing afterwards refuses by name rather than quietly reconnecting, because a
store that reopened behind you would hide a shutdown-ordering bug instead of
surfacing it. A client you passed in yourself is not terminated; this store
did not open it.
The driver-shape types are exported too, and here is why they exist. Nothing
in this package takes a hard type dependency on @google-cloud/firestore — it is
an optional peer most consumers never install, so a hard import would break
type-checking for everyone who does not have it. Instead the adapter declares,
locally, the exact slice of the driver's surface it touches, each shape read off
the installed types/firestore.d.ts (9.0.0). They are exported so you can type a
wrapper, a fake or a mapping of your own without reaching into dist:
| Type | What it is |
|---|---|
FirestoreLike | One connected database — collection(), runTransaction(), terminate(). This is the shape a pre-built client passed as firestore has to satisfy. |
FirestoreCollectionLike | A query that can also mint document handles: FirestoreQueryLike plus doc() |
FirestoreQueryLike | The query builder — where, orderBy, startAfter and limit are synchronous and each returns a new query; only get() is async |
FirestoreQuerySnapshotLike | The answer to a query: docs, an already-materialised array — not a stream, and not an async iterable |
FirestoreDocumentReferenceLike | A handle on one document: id, get(), delete() |
FirestoreDocumentSnapshotLike | One document as it came back: exists (a property, not a method), id, and data() |
FirestoreTransactionLike | The transaction handle, whose asymmetry a fake has to get right: get() is async and answers a snapshot, set() is synchronous and answers the transaction |
FirestoreConstructorLike | The Firestore class itself — new (settings?) => FirestoreLike |
FirestoreSdkModule | The module as this adapter loads it: Firestore and FieldPath.documentId(), the two members it needs |
What it costs to run is in the cost list with the others.
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, on the right transport (9.32.0). The refutation above stands
unchanged — this is not support for Google's identity path and will never be
described as one. What changed is where the seam lives. Until 9.32 the only
route was the generic http transport, which has a custom fetch and fixes
its headers at connect time: you gave up token rotation to get a client
certificate, which is a bad trade nobody should have to make. gatewayTransport
now takes a fetch of your own, called underneath the per-request vending:
transport: gatewayTransport({
url, credentials, service: 'gateway',
fetch: (input, init) => fetch(input, { ...init, dispatcher: mtlsAgent }),
}),The credential is vended and applied first, so your signer sees the final headers and has the last word over the bytes — and the vend still happens on every request. Zero vendor code lands here: you build the mTLS agent and the DPoP proof, this library carries the MCP session over it. Bring-your-own, offered as a seam. 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 implementation, anywhere. That is what Google's Agent
Gateway requires of an agent identity, and this library ships no certificate
loader and no signer for it — nor will it. Since 9.32
gatewayTransporttakes afetchof your own so you can build one without losing per-request vending; the seam is the whole of what is offered. - 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. - Firestore sessions: one transaction per persisted turn — one document read
then one write, where the read is the write-once ownership rule and not an
overhead you could optimise away. One document read per hydrate and per
ownerOf. One indexed query perlistByUserpage, whose cost is the page rather than the collection — which is the whole reason the listing is indexed and cursored. One delete perforget. Storage is the serialized envelope plus five small fields, and the one composite index. - 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. Its optional peer carries five transitive moderate advisories; the offered --force downgrade will not be taken |
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). Still not Google's identity-enforced path — that path is mTLS + DPoP and this library implements neither; 9.32 adds a fetch seam so your own signer composes UNDER the vending instead of forcing you onto the http transport and losing rotation |
| Agent Runtime hosting | — | No adapter — but Node deploys, verified (2026-08). Recipe above. The remaining work is a tracked ticket, not an unknown: the contract's second route is NDJSON at its own path and httpHost serves one path framed as SSE, so a host adapter is a port-shape change and its own release |
firestoreSessions() — a SessionLifecycle on Firestore | agentfootprint/hosting | 9.33.0; field-validated except the ownership refusal (2026-08). An independent trial ran this adapter against a real Firestore and exercised seven of its eight areas live — round trip, indexed and cursored listing, the missing-index refusal, the hashed document name, the size ceiling, derived ownership and forget. The eighth, refusing a turn signed by somebody else, was added after that trial and is held by tests here and by nothing in the field. Two caveats stand: the eighteen pinned SDK members were hand-verified against a real @google-cloud/firestore 9.0.0 install outside the repo, and the reality assertion skips in CI while the dispatch pin runs everywhere. Full account |
| 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, Microsoft Foundry is the same map for a
Foundry project (foundry() instead of gemini(), entraIdentity() instead of
googleIdentity()) — a younger column whose Status rows say plainly that
nothing there has met live Azure yet — 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.
- Microsoft Foundry — the youngest column, mapped the same way.
