Write an agent.yaml, and the agent is deployed to Agent Runtime
(Vertex AI Agent Engine), registered with Gemini Enterprise, and wired to
per-user authorizations.
People adding an agent do not write Python. Instructions live in Markdown; connections and tools are declared in YAML. Python is only needed for tools whose logic you implement yourself.
The name is "gate", Gemini flavoured: the gate agents walk through into Gemini Enterprise.
0.1 is being assembled. The pieces below exist and are tested; the first release follows once an existing installation runs on them unchanged.
agent.yaml ──validate──▶ archive ──▶ Agent Engine ──register──▶ Gemini Enterprise
│ │ │ │
gete.yaml gete_entry.py reasoning engine authorization per
policies/*.yaml agent.resolved (Terraform module) agent × connection
connections requirements.txt registration → engine
- Declarations —
gete.yaml(project, policies, connections) and oneagents/<name>/agent.yamlper agent. JSON Schemas reject unknown keys. - Policies — rules every agent gets from the outside: text put in front of the instruction, redaction of tool results, confirmation of writes. gete fixes the shape; the text is yours.
- Connections — external services read with the user's token, which
Gemini Enterprise hands over per authorization. gete's client only sends a
token to the hosts the connection declares, and only when it has the
connection's shape. That guard covers builtin and MCP tools; a python tool
is handed the caller's token to work with, so where the token goes from
there is its code's doing — reviewing an agent's
src/is reviewing that. - Shared credentials — the opposite trust model, for writes that have no per-user token to ride on: one credential the agent holds, acting for whoever calls it. The tools and their guardrails ship with gete; a declaration can only switch them on.
- Runtime — builds the ADK agent from
agent.resolved.yaml, carries the user's token to the tools (builtin, MCP, OpenAPI, python), redacts what comes back. - Delivery — a deterministic archive, a Terraform module, and
registerfor the parts Terraform has no resources for.
uv tool install "gete[cli] @ git+https://github.com/pepabo/gete"
gete init mail-triage # gete.yaml, policies/example.yaml, agents/mail-triage/
$EDITOR agents/mail-triage/instruction.md
gete validate
gete run mail-triage # talk to it locally
gete terraform # one module call per agent, under terraform/
gete register # once the engines exist: authorizations and the listingThe rest of the way — the GCP project, the Terraform root the generated module calls need, and the one registration a person makes by hand — is in docs/quickstart.md. That install resolves from whatever index your uv names, PyPI unless you say otherwise; the index it resolves from covers pointing it at a mirror.
| Command | What it does | Touches GCP |
|---|---|---|
gete init <name> |
Scaffold an agent (and the project if there is none) | no |
gete validate [--check-secrets] [--import-check] |
Schemas and rules; optionally secrets' versions and a deployment-shaped import | --check-secrets reads |
gete run <name> |
Local conversation; tokens from GETE_TOKEN_<CONNECTION> |
calls the model |
gete graph [name...] |
Mermaid diagram of agents, engines, tools, connections | no |
gete connections [id] |
Catalog plus your own; with an id, what a person must prepare | no |
gete archive <dir> [--out file] |
The tar.gz Agent Engine receives; --external for Terraform |
no |
gete terraform [--out dir] [--check] |
Generate module calls; --check fails when stale |
no |
gete register [name...] |
Create/update authorizations, bring registrations in line | writes |
Exit codes: 0 on success, 1 when a check fails. register exits 0 when steps
remain for a person (they are written to registration-notice.md) and 1 only
when an agent could not be processed at all.
gete.yaml
policies/
finance.yaml
agents/
mail-triage/
agent.yaml
instruction.md
partner-review/
agent.yaml
instruction.md
src/ python tools, packaged with the agent
requirements.txt their extra dependencies
terraform/ generated by `gete terraform`
See examples/minimal for the smallest working project.
name: partner-review
display_name: Quarterly partner review
description: Aggregates spend per partner from freee and drafts the review.
model: gemini-2.5-flash
instruction: ./instruction.md
connections: [freee] # read with the user's own authorization
tools:
- mcp:
url: https://mcp.freee.example/mcp
connection: freee # the user's token rides along as Authorization
allow: [get_deals, get_partners]
effect: read
does_not: Does not create, update, or approve anything.
- python:
ref: partner_review.agent:TOOLS
effect: read
source: ./src
runtime:
agent_engine:
env: {FREEE_COMPANY_ID: "123456"}
secret_env: {SOME_TOKEN: some-secret-name} # names only; values stay in Secret Manager
registration:
gemini_enterprise:
engine: my-app_1234567890 # from the console URL; omit to deploy without listingTools that do not say effect: read count as writes, which is what the
has_write_tools policies key on.
allow and effect are declared per mcp: block, so one server's reads and
writes are split by naming it twice — the same url and connection, two
lists of tool names, two effects. Where a server hands out one grant for both,
that is the only place an agent can say it means to read.
A service that publishes an OpenAPI description but runs no MCP server can be declared without writing Python:
tools:
- openapi:
spec: ./specs/helpdesk.yaml # read at packing time, travels in the archive
connection: helpdesk
operations: [ListSearchResults, ShowTicket, ListTicketComments]
effect: read
does_not: Results are the caller's own view; not found is not proof of absence.
params:
ListSearchResults:
query: {prefix: "type:ticket "}
per_page: {value: 25}
describe:
ListSearchResults: Search tickets. The kind is fixed to tickets.
- openapi:
spec: ./specs/helpdesk.yaml
connection: helpdesk
operations: [UpdateTicket]
effect: write
only:
UpdateTicket: [ticket_id, ticket.comment.body] # all the model may write
params:
UpdateTicket:
ticket.comment.public: {value: false} # internal note, never mailoperationsis required, never defaulted. A published description holds far more than an agent means to expose — hundreds of operations is normal — and forgetting to choose must not mean offering everything. Operations are picked byoperationId, which also becomes the tool's name.- Request URLs are built from the connection's
base_url. The description's ownserversare never read: a published root may carry variables, a stale default, or another tenant. The client's destination check and token rules hold exactly as for every other request. paramskeeps what the code it replaces used to enforce.valuefixes a parameter and takes it out of what the model sees — its value is declared, so there is nothing left for the model to say.prefixandsuffixwrap what the model writes; the declared text comes first, so nothing the model writes can displace it.- A dotted name reaches into the JSON body. Services commonly nest what
matters: whether a helpdesk comment goes out to the requester is a boolean
two levels down.
ticket.comment.public: {value: false}pins it there — the leaf disappears from what the model sees, and the declared value is written wherever its parent object is sent, overwriting anything found there and never conjuring the parent up. A name that matches a parameter literally keeps meaning that parameter. onlynames what the model may write. Published update operations accept the whole record — status, assignee, tags — when an agent is only meant to add a comment. Everythingonlyleaves unlisted is taken out of the declaration and never sent, even smuggled into the arguments; aparamsvalue still rides. A name a parameter carries literally stays that parameter's, as withparams. Counting up what goes out fails safe as the description grows: a new field stays unexposed until someone declares it.describereplaces the vendor's text. Vendor descriptions are written for developers sitting next to the docs and often cite links a model cannot follow;does_notis appended to every tool, as withmcp:.- The description is fixed at packing time.
gete archivetakes the file into the archive and the runtime reads it from there, so a vendor editing their published description changes nothing until someone re-archives deliberately. - The archive carries only what was declared. Keep the vendor's
original in the repo;
gete archiveprunes it to the declared operations, path-level parameters and every referenced component riding along. Cutting a description down by hand breaks quietly — path-level parameters fall away, a flattened$reftakes its arguments with it, andvalidatecannot tell such a description from one that never declared them — so the cutting is gete's job, andvalidatenow also reports a{placeholder}in a path that no path parameter declares. The packing then holds the declaration against the pruned description, so a reference pruning cannot keep is refused before anything deploys. - Writes ride the same rails. PUT, PATCH, and DELETE operations must
sit in a block declared
effect: write, which the confirmation policies key on, and results pass the same redaction as every other tool. A change that may already have been applied is never resent — for a DELETE, what it removed usually cannot be brought back, so a confirmation policy on write tools is worth having before declaring one.
gete connections lists what ships: freee, google, github, notion-mcp,
zendesk, and slack (retired, with the reason). Add your own or override a
catalog entry in gete.yaml:
connections:
github:
base_url: https://api.github.example.com # GitHub Enterprise
internal-api:
display_name: Internal API
hosts: [api.internal.example.com]
token_prefixes: []
oauth:
authorization_url: https://auth.internal.example.com/authorize
token_url: https://auth.internal.example.com/token
scopes: {read: Read internal data}A hosts entry is an exact host name; nothing is matched by suffix. When one
host serves unrelated APIs side by side — www.googleapis.com carries Drive
and Calendar next to GCP's storage and compute — the entry can be scoped to a
path prefix, written host/path/, and requests must stay below that path.
A bare entry admits every path on its host, so declaring the same host bare
next to a scoped entry — or setting base_url on that host, which lists it
bare — is reported: the scoping would silently not happen.
A connection's oauth.scopes go to every agent that declares it, so they stay
a read-only minimum. Scopes under oauth.optional_scopes are a menu: an agent
gets one only by selecting it in its own declaration, and the selection lands
in that agent's own authorization, so consenting to one agent's writes grants
nothing to any other. A scope outside the menu is refused by gete validate.
# agent.yaml
connections:
- freee # the defaults only
- id: google # the defaults plus a selection from the menu
scopes: [https://www.googleapis.com/auth/spreadsheets]oauth.pkce: true asks Gemini Enterprise to carry a code challenge through
the flow. An authorization server that requires PKCE refuses the code exchange
without one, and there is no other way to ask for it from a declaration.
token_prefixes: [] says the service does not announce itself: a token is
taken as its own once no other connection's prefix matches it. Two such
connections cannot be told apart, so an agent may hold only one of them.
Declaring a second one in gete.yaml is fine; naming both under one agent's
connections is what gete validate refuses.
A service whose root moves with the installation — the tenant in a subdomain,
or a deployment you host — writes its URLs around {base_url}, and the
installation fills it in:
connections:
rooted-api:
display_name: Rooted API
hosts: [] # the only host comes from base_url
base_url: https://acme.example.com
oauth:
authorization_url: "{base_url}/oauth/authorizations/new"
token_url: "{base_url}/oauth/tokens"
scopes: {read: Read data}Leave base_url out and nothing the connection declares is an address: no
host is added, and gete validate refuses it where an agent names it. That is
how a definition reaches the catalog without knowing a tenant. Writing a
stand-in host instead would put a name a stranger can register on the list of
places a user's token may be sent.
Some of what a connection needs is not gete's to do. The OAuth client is
registered by a person, at the provider, once; setup is where a connection
says so, and gete connections <id> prints it next to the secret names and the
redirect URI that registration asks for at the same moment:
connections:
internal-api:
setup: |
Register an OAuth client in the service's admin console.
Put the client id and secret in Secret Manager under the names above.
The consent screen is the service's own; it grants writing as well.Prose, not a checklist. What matters most about a connection is often not a step — what the consent screen actually grants, which providers hand out no way to delete a client again — and no check can see any of it.
$ gete connections internal-api
internal-api Internal API
...
client id ge-oauth-internal-api-client-id
client secret ge-oauth-internal-api-client-secret
redirect uri https://vertexaisearch.cloud.google.com/oauth-redirect
Before anyone can authorize:
Register an OAuth client in the service's admin console.
...
Adding a connection to the catalog is one YAML file under
src/gete/catalog/connections/; the conformance tests check it.
A connection reads with the caller's token. Some writes have no such token
to ride on — Slack posting is one, which is why the slack connection is
retired — so gete also ships tools that act with a credential the agent
holds. Whoever can call the agent acts through that credential; the tools
and their guardrails ship with gete, and a declaration can only name them:
# agent.yaml
shared_credentials: [slack_post]
# gete.yaml — the secret is named once for the project
shared_credentials:
slack_post:
token_secret: slack-bot-token # Secret Manager secret holding the xoxb- tokenslack_post previews a post, posts it as the bot once the user approved,
and reads a single linked message — never the channel around it. The fence
moves with the destination: a public channel takes inviting the bot, a
private channel takes its ID in the agent's SLACK_ALLOWED_PRIVATE_CHANNELS
env, and direct messages are never posted to. Text the policies' redact
patterns would change is refused rather than masked, and who posted where is
logged — never what. Declaring the credential counts as has_write_tools,
and policies can key on has_shared_credentials.
Delivery wires token_secret into the deployment's secret_env; the agent
neither writes nor can change which secret the credential comes from. The
Slack app behind the token needs a bot user with chat:write,
channels:read, groups:read, channels:history, and groups:history —
and not chat:write.public, which would let the bot past the invitation
fence. Locally, gete run reads the token from SLACK_BOT_TOKEN.
uv sync --all-extras
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run mypyThe version is derived from git tags (hatch-vcs); it is not written in
pyproject.toml. Everything in this repository is written in English.
Apache-2.0