Skip to content

Repository files navigation

gete

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.

Status

0.1 is being assembled. The pieces below exist and are tested; the first release follows once an existing installation runs on them unchanged.

How it fits together

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 one agents/<name>/agent.yaml per 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 register for the parts Terraform has no resources for.

Quick start

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 listing

The 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.

Commands

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.

A project

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.

agent.yaml

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 listing

Tools 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.

Tools from an OpenAPI description

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 mail
  • operations is 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 by operationId, which also becomes the tool's name.
  • Request URLs are built from the connection's base_url. The description's own servers are 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 — google lists one host per API and deliberately declares no root — leaves nothing to build URLs from, and rooting the whole installation in gete.yaml would point every block of that connection at one API. base_url on the block picks the API that block speaks to; validate holds it against the connection's hosts (below the path for a host/path/ entry), so the ceiling does not move. Where both roots are declared, the block's wins.
  • params keeps what the code it replaces used to enforce. value fixes 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. prefix and suffix wrap 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.
  • only names 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. Everything only leaves unlisted is taken out of the declaration and never sent, even smuggled into the arguments; a params value still rides. A name a parameter carries literally stays that parameter's, as with params. Counting up what goes out fails safe as the description grows: a new field stays unexposed until someone declares it.
  • describe replaces the vendor's text. Vendor descriptions are written for developers sitting next to the docs and often cite links a model cannot follow; does_not is appended to every tool, as with mcp:.
  • The description is fixed at packing time. gete archive takes 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 archive prunes 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 $ref takes its arguments with it, and validate cannot tell such a description from one that never declared them — so the cutting is gete's job, and validate now 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.

Connections

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 else

An 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.

Connections gete issues tokens for

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_id and the key, backdated a minute and valid for less than ten, and finds the installation through the first of repositories the App is installed on (GET /repos/{owner}/{repo}/installation);
  • asks for an installation token narrowed to repositories and permissions, and reuses it in the process until a few minutes before its expires_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_secret reaches the deployment as GETE_APP_KEY_GITHUB_APP, which the agent cannot set itself. The App ID and the ceiling travel in the resolved declaration. gete run reads 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 graph marked (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.

Shared credentials

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- token

slack_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.

Development

uv sync --all-extras
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run mypy

The version is derived from git tags (hatch-vcs); it is not written in pyproject.toml. Everything in this repository is written in English.

License

Apache-2.0

About

Declare agents in YAML, deploy them to Vertex AI Agent Engine, and register them with Gemini Enterprise

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages