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...] [--reset-authorization <agent>-<connection>] |
Create/update authorizations, bring registrations in line; the flag resets one authorization and everyone approves it again | 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. - A block may name its own root with
base_url. A connection that spans several APIs —googlelists one host per API and deliberately declares no root — leaves nothing to build URLs from, and rooting the whole installation ingete.yamlwould point every block of that connection at one API.base_urlon the block picks the API that block speaks to;validateholds it against the connection'shosts(below the path for ahost/path/entry), so the ceiling does not move. Where both roots are declared, the block's wins. 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, freee-mcp, google, github,
github-app, notion-mcp, slack-mcp, and zendesk. 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.
The same goes for prefixes that overlap. A service that runs in more than one place — github.com, and a GitHub Enterprise Server of your own — issues the same token shapes from each, so the second one is a connection under its own id, with a complete definition and the prefix its tokens carry:
connections:
github-ghes:
display_name: GitHub Enterprise Server
base_url: https://ghe.example.com/api/v3
token_prefixes: [ghu_]
oauth:
authorization_url: https://ghe.example.com/login/oauth/authorize
token_url: https://ghe.example.com/login/oauth/access_token
scopes: {}One agent declares github and another github-ghes, and each token goes
only to the hosts of the connection it arrived for. An agent naming both is
refused: a ghu_ token would pass as either's. Leaving token_prefixes empty
is not a way around declaring the prefix — a connection that accepts tokens by
elimination refuses every token that carries a prefix declared anywhere in
the project or the catalog, whichever agent holds that connection.
Some services announce themselves without a prefix: their access tokens are
JWTs whose iss claim names the service's own host. tokens.format: jwt says
so, and gete holds the connection to it — a token that is not such a JWT is
refused, however little else claims it. In return the connection is no longer
accepted by elimination, and an agent may hold it next to one that is:
connections:
zendesk:
base_url: https://acme.zendesk.com
tokens:
format: jwt # tokens are JWTs issued by acme.zendesk.com, nothing elseAn agent may then read Zendesk and write to internal-api above, which keeps
the one place a token is taken by elimination. What the two may not share is
an issuer: one authorization server in front of both services puts its host
in either connection's tokens, and gete validate refuses that pairing for
the same reason it refuses two anonymous connections.
Whether a provider issues such tokens can be a setting rather than a promise —
Zendesk does so only with token expiry turned on — so the declaration belongs
in gete.yaml, not in a catalog entry that would promise it for every
installation. It cuts both ways: turn that setting off and every token the
connection is handed is refused until the declaration goes with it.
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.
...
Two texts reach end users, and an installation writes them in its own
language. messages.reauthorization is shown when no token for the connection
arrived: Gemini Enterprise holds no credential for the user yet, so approving
the connection there is what helps. messages.rejected is shown when a token
arrived and the service refused it. Approving again does not help then —
Gemini Enterprise shows its consent screen only while it holds no credential,
and it never asks the provider whether the one it holds is still good — so the
text sends the user to an operator, who resets the authorization:
gete register --reset-authorization <agent>-<connection>That unlinks the authorization from the registration that holds it, deletes it, and recreates and binds it in the same run; every user of that agent approves the connection again. The name has to match a declared agent and one of its connections, since the run deletes what it names. It is a command for the day it is needed: left in a CD invocation, it would send every user back to the consent screen on every release.
connections:
internal-api:
messages:
reauthorization: Approve Internal API in Gemini Enterprise and try again.
rejected: Internal API refused the authorization; ask the operator to reset it.Adding a connection to the catalog is one YAML file under
src/gete/catalog/connections/; the conformance tests check it.
Some reads have no user's token behind them either: the agent is meant to
read the same repositories whoever calls it. github-app is a connection whose tokens gete issues itself, from
a GitHub App's private key, instead of receiving them from Gemini Enterprise.
Agents use it from openapi blocks (and python tools through gete's client)
exactly like any other connection — operations, params, does_not, and
the host check all apply as before:
# gete.yaml
connections:
github-app:
base_url: https://ghe.example.com/api/v3 # leave out for github.com
app:
app_id: 123
private_key_secret: ge-github-app-private-key
# The ceiling of every token issued through this connection
repositories: [example-org/requests]
permissions: {issues: read}
# agent.yaml
connections: [github-app]
tools:
- openapi:
spec: ./specs/github.yaml
connection: github-app
effect: read
operations: [SearchIssues, GetIssue, ListIssueComments]
params:
SearchIssues:
q: {prefix: "repo:example-org/requests is:issue "}For such a connection gete:
- signs an RS256 App JWT from
app_idand the key, backdated a minute and valid for less than ten, and finds the installation through the first ofrepositoriesthe App is installed on (GET /repos/{owner}/{repo}/installation); - asks for an installation token narrowed to
repositoriesandpermissions, and reuses it in the process until a few minutes before itsexpires_at; - creates no Gemini Enterprise authorization and offers no reauthorization tool. A missing key, or GitHub refusing to issue a token, is reported to the user as text;
- delivers the key like
secret_env:private_key_secretreaches the deployment asGETE_APP_KEY_GITHUB_APP, which the agent cannot set itself. The App ID and the ceiling travel in the resolved declaration.gete runreads the PEM from the same variable. When the agent is built, before its own modules are imported, gete takes the variable out of the environment and keeps the key to itself; - draws the connection in
gete graphmarked(bot), like a shared credential.
repositories must share one owner, since a token comes from one
installation. permissions is required: left out, a token would carry
everything the installation was granted. Whoever can call the agent acts as
the App within that ceiling, whatever they could reach on GitHub
themselves, so keep it to what the agent's tools read. mcp blocks cannot
use an app connection yet.
The ceiling binds the tokens gete issues, not the key. Python tools run in the same process as gete, and code that goes looking for the key there can find it and issue a token with the installation's whole grant. Taking it out of the environment keeps it away from tools reading their settings and from processes they start; it is not a sandbox. Grant the App itself no more than the agents holding the connection may do, and review the python tools of those agents as code that holds the key.
A connection reads with the caller's token. Some writes have no such token to ride on — posting to Slack from an agent nobody has authorized is one — 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