dev-portal.ai
How we built it

From an ADK agent to an application people can work in

The Google Agent Development Kit gave us a working agent in an afternoon. Everything after that — the part where other people can actually use it — took the rest of the week. This is what we built, what it cost, and the six things that broke.

August 2026 · DevPortal · Watch the 4-minute demo

The gap

ADK builds an agent well. You get a tree of BaseAgents and LlmAgents, a runner, tools, plugin hooks around every model and tool call, an A2A card builder that reads your own tree, and memory services that reach Vertex AI Memory Bank. At the end of it a developer is holding a working agent.

An application is what the rest of the organization holds. An approver needs to see what is about to happen and consent to that exact thing. An auditor needs the chain. An operator needs the agent to act through a fence, carrying its own identity, with no standing keys. And all three need somewhere to stand that is not a chat window.

Every team that closes this gap hand-builds the same four things per workflow: a status UI, an approval flow, credential glue, and an audit trail. We built those four once, as a platform, and made an agent-backed workflow a thing you deliver into it.

Decision one: the agent lives outside the platform

Our first cut ran the agent loop inside the platform process. It worked, and it was wrong. A platform that runs your agent is a platform you have to move your agent into, and every claim we wanted to make about governance was really a claim about our own code calling itself.

So we pushed the agent out. It became its own deployment on Cloud Run, with its own lifecycle, its own container, and its own identity — the platform neither starts it nor holds its credentials. That one change forced the architecture into two arrows, and the fence stopped being decorative.

The two arrows

Inbound, the platform is a client of the agent. It reads runs, the agent card, health and memory the way any external system would. The agent reports every step it takes to POST /_platform/agent-runs/events. That is how the app shows runs it never started, and the app holds an event stream open to the browser so each step appears the moment it is reported.

Outbound, the platform is the agent's policy enforcement point. Every call the agent makes to the world crosses a capability broker carrying the agent's own identity. Reads execute. Mutations stop.

The app pack and platform on Cloud Run, the capability broker as the single fence, the external ADK agent deployment, and the GKE fleet beyond it
The app pack, the platform and its capability broker, the external agent deployment, and the fleet beyond the fence.

Building on ADK's extension points

We wanted the platform to be a consumer of ADK, never a rewrite of it. Six surfaces did the work, each used as an integration point rather than a thing to wrap:

ADK surfaceWhat the platform does with it
AgentCardBuilder Builds /.well-known/agent-card.json by walking the live agent tree. The Agents view renders that composition — orchestrator, sub-agents, which are model-driven, their tools — so a deployment is discovered by asking it what it is.
BasePlugin Turns before_model / before_tool / after_tool / on_tool_error into run events the user watches. Every hook returns None, so narration cannot alter what the agents do.
VertexAiMemoryBankService Four scoped memory kinds with server-side consolidation, surfaced as a view the user reads rather than a developer artifact.
BaseAgent, LlmAgent One custom orchestrator running deterministic and model stages in a single tree. Every run event carries the sub-agent that produced it.
FunctionTool Three read-only tools. Each reaches the world through the broker with the agent's own workload token, never a service-account key.
InMemoryRunner One runner invocation per poll and per model step, with ADK events correlated to a run through session state.

The useful measure of whether this stayed generic is how much of it knows what an incident is. Five modules do the ADK integration and none of them do:

ModuleLinesJob
events_plugin.py72Narration plugin — ADK hooks to run events
introspection.py97Agent-card introspection from any tree
platform.py145Reporting client with workload-token auth
model_armor.py184Guardrail around any model call
memory.py507Memory Bank wrapper, four scoped kinds

Seventy-two lines is what separates an investigation you can follow from a spinner. Point the Agents and Memory views at a different ADK deployment and they render it.

The fence: approval bound to an exact payload

A “Yes” button over a blob of JSON is not consent. The thing a human approves has to be the thing that executes, and nothing else.

So the broker treats reads and mutations differently. A read executes. A mutation becomes a pending_approval intent carrying an HMAC digest computed over its payload. A human approves that digest. The agent re-presents it on its next poll, and the broker commits exactly once:

agent            broker                        human
  │                 │                             │
  ├─ setEnvVar ────▶│  policy gate: allowlisted?  │
  │                 │  digest = HMAC(payload)     │
  │                 ├─ pending_approval ─────────▶│  sees method, target,
  │                 │                             │  payload, diff, reasoning
  │                 │◀──────────── approve(digest)┤
  ├─ re-present ───▶│  digest matches? nonce
  │                 │  unspent? not expired?      │
  │                 ├─ execute once ──▶ GKE       │
  │◀─── executed ───┤                             │

Payload drift invalidates the approval — change one byte and the digest no longer matches what the human agreed to. A nonce and an expiry make it single-use. And the method allowlist has teeth: a method the deployment never declared is denied outright, not routed into an approval nobody should have been asked for.

Identity is bound from the presented workload token at every fence, never from the request body. An agent, an isolate, or an app can only be what its token says it is.

Two failure policies for one guardrail

The planner's prompt carries live container logs. Anything that can write to a container's stdout can put text in that prompt, so Model Armor checks it before the model sees it, and checks the model's output before anything is parsed into an action.

The part we got wrong first was the failure policy — because there are two of them, and a single policy gets one wrong:

Both policies are disclosed on the agent card, so nobody has to guess which one they are living under.

Memory that changes later behavior

Memory only matters if it alters what happens next. Vertex AI Agent Engine Memory Bank holds four scoped kinds, and Vertex consolidates them server-side, so three incidents that said the same thing become one rule the planner reads first.

The kind we did not plan for turned out to be the most interesting. When a human rejects a finding, the pattern is written down as known-benign — and a recurrence comes back marked advisory instead of paging anyone. In one session the agent raised an unsolicited run, a human rejected it, and it never raised it again.

Serving a pack's API with no keys

An App Pack is a full-stack app dropped into the platform's shell. It declares the capabilities it needs, renders the agent's work as the user's content, and gives the human a hand on it.

Its API runs in a workerd V8 isolate inside the same Cloud Run container — holding no keys, with loopback-only egress to the broker. If a pack is compromised it has nothing to steal and nowhere to call. An absent optional capability answers 503 naming the port rather than faking a reading, and every response discloses who served it via x-served-by.

Six things that broke

Identity has a lifetime, and we forgot to give ours one.

The token issuer's one-hour default meant the agent failed closed after an hour — correctly, visibly, and uselessly. Long-lived deployments need long-lived identity. Seven days now, and the agent publishes its own expiry on its card.

A human's “no” has to be terminal on both sides of the fence.

The broker drops a rejected intent, so the agent saw its digest vanish and read that as a transient denial. It would have re-proposed the same fix every poll, forever. A rejection now ends the run — and when a digest is merely gone, memory records exactly that, not a human decision it cannot vouch for.

One ADK invocation per model step.

Our first cut ran every planner inside a single invocation with the evidence in session state. ADK resolves the instruction once, so seven runs all reasoned over the first run's evidence. Each step gets its own session now, with the evidence as the user message.

Narration must not be able to change behavior.

It is tempting to let a plugin hook mutate a tool call. Every hook returns None, which makes the run log a description of what happened rather than a participant in it.

A run log has to outlive the container that wrote it.

The append-only event log is mirrored to Cloud Storage and replayed at boot, so a workflow's history survives a redeployment instead of resetting with the revision.

Externalizing the agent made the product honest.

This is the first lesson restated, because it was the one that mattered. While the loop lived inside the platform, the platform was running the agent — the opposite of what we were claiming.

What a second pack costs

One pack is real today; the other templates are stubs. The split we measured on the reference pack:

Reused unchanged: the ADK integration above, the broker and its digest fence, the Model Armor guardrail, the app host, router, run-event stream and workerd deployment, workload identity, the run registry and Agents Capability, the app shell with its design system, and the k8s-remediation and GCP-observability capabilities.

Written fresh: detection rules, a bounded action vocabulary, domain views, and an app.yaml. In this pack that is roughly 1,550 of 2,662 Python lines and 1,180 of 1,783 server lines. The rest is platform.

That ratio is an estimate until a second pack tests it, and two are scaffolded in the repo. Neither of them watches a fleet, which is the useful part of the test.

What it does end to end

The reference pack watches a 12-service Online Boutique fleet on GKE. On one real run from the live Cloud Run deployment — incident INC-7367C1D0, 24 events:

MomentMeasured
Fault injected → case opened15 s
Detection → proposal (memory recall, 2 tool calls, Model Armor both sides)20 s
Human deciding2 m 25 s
Approval → executed16 s
Approval → verified and resolved57 s

Detection, recall, investigation, root cause, proposal, execution, verification and documentation are the agent's. The consent is the operator's. The only pause in the whole thing was a person reading.

Built with: Google Agent Development Kit · Gemini 3.5 Flash on Vertex AI · Vertex AI Agent Engine Memory Bank · Model Armor · GKE · Cloud Run · Cloud Storage · Cloudflare workerd · TypeScript with a Cordis plugin microkernel · Python and FastAPI · React with server-sent events.

Open the reference app · Watch the demo

We created this piece of content for the purposes of entering the All Things Agentic Hackathon.