| name | gatehouse |
| description | Use when calling any authenticated external HTTP API, when an API key or bearer token is needed, when the user mentions credentials, secrets, vault, tokens, or keys, when a 401/403 comes back from an upstream API, or when picking up temporary database or SSH credentials (dynamic secrets, checked out via gatehouse_checkout). Routes all credential access through the Gatehouse vault so raw secrets never enter the agent context window. |
Gatehouse
A Gatehouse vault holds credentials for this environment. You never
see raw credential values. You describe HTTP requests and Gatehouse
injects secrets server-side.
Connect
The three env vars GATEHOUSE_URL, GATEHOUSE_ROLE_ID, and
GATEHOUSE_SECRET_ID hold everything you need. Never read, print,
echo, log, or pass role_id or secret_id as literals in code, tool
calls, or output. Read them from the environment only at the moment
you exchange them for a JWT.
If those vars are not already in the environment, the installer wrote
them to a .env.gatehouse file at onboarding time: ~/.claude/.env.gatehouse
under Claude Code, ~/.pi/agent/.env.gatehouse under Pi, or
.env.gatehouse in the working directory otherwise (use the path your
harness was given at install). Source it just before you log in, then
read the vars: set -a; . <path>; set +a. If neither the vars nor the
file exist, stop and tell the operator to re-run onboarding.
Login: POST {GATEHOUSE_URL}/v1/auth/approle/login with body
{"role_id": "$GATEHOUSE_ROLE_ID", "secret_id": "$GATEHOUSE_SECRET_ID"}.
Store the returned JWT in memory only. The JWT expires in 24h.
To extend a session before expiry without re-reading role_id/secret_id,
call POST {GATEHOUSE_URL}/v1/auth/refresh with the current valid JWT
as Authorization: Bearer <jwt>. The response is a fresh JWT with a
full 24h TTL. Refresh is the recommended path for long-running agents
(it also re-checks AppRole suspension and IP allowlist, so a revoked
role can't keep refreshing). On 401 from /refresh, the token is
already expired or the role was deleted, fall back to a full re-login
from role_id/secret_id.
Prefer the streamable HTTP MCP endpoint at {GATEHOUSE_URL}/v1/mcp
when your harness supports it. The tools listed below are exposed
there natively.
HTTP fallback (when MCP tools aren't wired up)
If your harness can only make raw HTTP calls, every gatehouse_* tool
below maps to an authenticated endpoint. Send Authorization: Bearer <jwt> on each.
If your harness has neither MCP tool wiring nor a tool listed below,
/v1/mcp also accepts standard JSON-RPC over HTTP. POST with
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"<tool>","arguments":{...}}}
and the same Bearer JWT. Every gatehouse_* tool is callable that way
under the same auth.
Login from a shell
When you're calling Gatehouse from bash/curl with no MCP wiring,
use this pattern verbatim. It keeps role_id, secret_id, and the JWT
out of your context window:
umask 077
JWT=$(curl -fsSL -X POST -H "Content-Type: application/json" \
-d "$(jq -nc --arg r "$GATEHOUSE_ROLE_ID" --arg s "$GATEHOUSE_SECRET_ID" \
'{role_id:$r,secret_id:$s}')" \
"$GATEHOUSE_URL/v1/auth/approle/login" | jq -r .token)
printf '%s' "$JWT" > /tmp/gh.jwt
unset GATEHOUSE_ROLE_ID GATEHOUSE_SECRET_ID JWT
Subsequent calls:
curl -fsSL -H "Authorization: Bearer $(cat /tmp/gh.jwt)" \
"$GATEHOUSE_URL/v1/secrets"
To extend before the 24h JWT expiry without re-reading role_id or
secret_id:
JWT=$(curl -fsSL -X POST \
-H "Authorization: Bearer $(cat /tmp/gh.jwt)" \
"$GATEHOUSE_URL/v1/auth/refresh" | jq -r .token)
printf '%s' "$JWT" > /tmp/gh.jwt
unset JWT
MCP tool to HTTP endpoint map
| Tool | HTTP |
|---|
gatehouse_list | GET /v1/secrets?prefix=<p> returns {"secrets": [...]}. Static and dynamic entries are merged; each entry has a kind: "static" | "dynamic" field. prefix is starts-with on the full path (prefix=api matches api-keys/..., NOT services/api-foo). |
gatehouse_patterns | GET /v1/proxy/patterns?secret=<path> returns {"patterns": [...]} with fields method, url_template, request_headers, request_body_schema, confidence. Static secrets only. |
gatehouse_proxy | POST /v1/proxy with the body shape in "Injection styles" below. Static secrets only. |
gatehouse_get | GET /v1/secrets/<path>/value (requires read). Static secrets only. |
gatehouse_lease | POST /v1/lease/<path> with {"ttl": 300}. Returns {lease, value}. STATIC secrets only. |
gatehouse_request_access | POST /v1/lease/<path>/request with {"ttl": 300, "justification": "<why>", "request_ttl": 3600}. Returns {lease_id, status, request_expires_at, expires_at_if_approved} (HTTP 202 first time, 200 on dedup re-request). Poll GET /v1/lease/<lease_id> and watch .status flip from pending to approved or denied. |
gatehouse_checkout | POST /v1/dynamic/<path>/checkout with {"ttl": 300}. DYNAMIC secrets only (SSH, DB). Returns {lease_id, path, provider_type, credential, ttl_seconds, expires_at}. |
gatehouse_revoke | DELETE /v1/lease/<lease_id>. Works for both static and dynamic lease IDs (dispatch is by vs prefix on the server). still works as an alias. |
First call to any secret, in order
gatehouse_list (prefix optional). Returns every secret you can
use, static and dynamic merged. HTTP response is
{"secrets": [...]}; MCP returns the array directly. Per-entry
fields:
kind: "static" (stored value, API key style) or "dynamic"
(ephemeral credential minted on demand, SSH cert or DB user).
Branch on this before picking a tool.
caps: capabilities you hold on this secret, e.g.
["read","proxy"]. Filter by caps.includes("proxy") when
you're looking for something to call through the proxy.
- Static only:
pattern_count (how many known-good request shapes
exist) and top_pattern (the highest-confidence pattern, e.g.
POST http://10.0.0.102:5230/api/v1/memos). top_pattern IS
your endpoint.
- Dynamic only:
provider_type (e.g. postgresql, ssh-cert) and
a metadata object with advisory routing info (allowed_hosts
for ssh-cert, host/port/database for DB providers). These
are not called via gatehouse_proxy; use gatehouse_checkout to
mint a credential.
Prefix is starts-with on the full path, not a substring match.
Use prefix=services/ to see all services/*, not prefix=memos
to find services/memos-pat.
- If
gatehouse_list returns an empty array, STOP. Your policy
grants nothing. Do not probe, scan, or guess endpoints, the
credential you need is not reachable by you. Tell the operator
their AppRole needs policies attached and wait. The same applies
mid-task: if the specific secret you need is missing from the
list, stop and ask, don't go looking for the upstream service by
hand.
- If
pattern_count > 0 for your target secret, call
gatehouse_patterns with the secret path and copy the
top-confidence pattern's method, URL, headers, and body schema.
Don't guess, don't probe, don't scan for ports. Another agent
already verified this shape.
- If
pattern_count == 0, read allowed_domains from the secret's
metadata, that's the canonical host. Still don't probe, use what's
listed. Your first successful call seeds the pattern for the next
agent.
Approval-gated secrets
Some secrets require human approval before you can use them. In
gatehouse_list, these appear with metadata.requires_approval: "true".
Do NOT try to proxy or lease them directly. The call will fail with 403
and requires_approval in the response.
The flow:
- Request access. Pick a TTL that covers what you need (default 300s,
max 86400s) and write a clear justification (1 to 3 sentences)
describing what API endpoint you'll hit and roughly what for.
- MCP:
gatehouse_request_access(path, ttl, justification).
- HTTP:
POST {{BASE_URL}}/v1/lease/<path>/request with
{"ttl": 300, "justification": "<why>", "request_ttl": 3600}.
Returns {lease_id, status, request_expires_at, expires_at_if_approved}.
- Wait. Poll every 30 to 60 seconds (NOT faster). Do not re-request;
duplicates dedupe to the same lease_id.
- MCP:
gatehouse_status shows your pending_leases count.
- HTTP:
GET {{BASE_URL}}/v1/lease/<lease_id> and check .status
for approved / denied.
- Once approved, your
gatehouse_proxy and gatehouse_lease calls
against this secret succeed for the lease duration. The approved
lease IS your access window; revoking it kills access immediately.
Each call audits as lease.access against that lease_id.
- If denied, read
denied_reason. Adjust your justification and
re-request if appropriate (a denied lease does NOT block re-requests).
- After expiry, you must re-request. Renew works on approved leases
(extends
expires_at within max_lease_ttl) but don't renew
speculatively.
- If the operator hasn't responded after about 10 minutes, surface to
the user ("waiting on approval for X") rather than spamming polls.
Writing justifications
The justification is shown to the human approver and lands in the audit
log. Write it for INTENT, not implementation.
GOOD: "Fetching Q4 revenue data from Salesforce for the quarterly
summary the user requested. Single GET to /services/data/v59.0/query."
BAD: "Need OpenAI." (too vague)
BAD: "POST {...full request body...}" (don't paste the body)
BAD: "Bearer " (NEVER include credentials)
NEVER include secret values, request bodies, raw URLs with query
params, or anything credential-shaped.
Dynamic secrets (SSH, DB)
Entries with kind: "dynamic" are NOT stored values, they are
generators. Each gatehouse_checkout mints a fresh credential on the
backend (a signed SSH certificate, an ephemeral DB user, etc.) that
auto-revokes at TTL. Treat them like leases with a single return value
and a clock.
- Discover them through
gatehouse_list like anything else. If a
dynamic secret you need is absent from the list, your policy
doesn't grant lease on it, apply the same "STOP, ask the
operator" rule as for static secrets.
- Check out with
gatehouse_checkout (MCP) or
POST /v1/dynamic/<path>/checkout (HTTP). The response body
includes a credential object whose shape depends on
provider_type: SSH cert configs return
{private_key, certificate, username, ...}, DB providers return
{username, password, host, ...}.
- Do NOT pass the credential to
gatehouse_proxy. Proxy only knows
static secrets. Consume the credential directly in your tool call
(SSH client, DB driver).
- Routing: the entry's
metadata in gatehouse_list and matching
fields on the checkout response tell you where to connect. For
ssh-cert, read credential.allowed_hosts and pick one of those
IPs/hostnames. For DB providers, credential.host /
credential.port / credential.database are your target. If
allowed_hosts is empty or absent, ask the operator instead of
guessing.
- SSH specifics:
- Read
credential.principals and use one of those as the SSH
username. The cert is bound to those names.
- Write
credential.private_key and credential.certificate to
TWO SEPARATE files using the OpenSSH sibling convention: pick a
base path like /tmp/gh_key and write the private key there
(mode 0600), then write the certificate to the same path with
-cert.pub appended (e.g. /tmp/gh_key-cert.pub). ssh -i /tmp/gh_key ... will auto-discover the cert. Use one shell
expansion (jq or python piped to > redirect). Do NOT
concatenate the two contents into one file: it will parse as a
malformed pubkey and SSH will silently skip cert auth.
- Prefer the sibling convention above to
-o CertificateFile=.
Some OpenSSH builds error out with Load key: error in libcrypto
when the cert is passed via the explicit flag depending on file
ordering or trailing-whitespace edge cases. The sibling
convention always works.
- Do NOT
cat, head, or otherwise echo the files to verify.
Many harnesses scrub credential-shaped strings from tool output,
so the content will look redacted even though the file is intact.
If you need to check the files landed, use (byte
count) or , which reveal nothing.
Operating rules
- Never echo role_id, secret_id, JWT, or any secret value into
context, logs, or output.
- For any authenticated outbound API call against a STATIC secret,
use
gatehouse_proxy. It keeps the credential out of your context
entirely.
- On 4xx/5xx from upstream, the
gatehouse_proxy response includes
a suggestions array of up to 5 verified patterns for that secret.
Use them before retrying.
- If an SDK refuses HTTP-level injection, fall back to
gatehouse_lease with a 60-600 second TTL. Call gatehouse_revoke
the moment you're done. Use gatehouse_get only if both proxy and
lease are unavailable.
- For DYNAMIC secrets (entries with
kind: "dynamic"), use
gatehouse_checkout, not gatehouse_lease or gatehouse_proxy.
See the Dynamic secrets section above.
- Before returning or logging any text that might contain a
credential (stack traces, tool output, echoed requests), pass it
through
gatehouse_scrub.
- Metadata is in
gatehouse_list already. Every secret's
kind, allowed_domains, header_name, auth_scheme, caps,
pattern_count, top_pattern, and provider_type are returned
by gatehouse_list. Don't fetch a secret by path just to inspect
metadata. Never call gatehouse_get just to see metadata, it
returns the raw value and requires read.
Injection styles for gatehouse_proxy
POST /v1/proxy (or the MCP tool) takes a flat JSON envelope. Keys
are top-level, NOT nested under request: or similar. Example:
{
"method": "GET",
"url": "https://api.example.com/data",
"headers": {"Content-Type": "application/json"},
"body": null,
"inject": {"Authorization": "api-keys/example"}
}
headers and body are optional; inject, auto_inject, or template
placeholders inside url/headers/body are how you reference secrets
without ever seeing their values.
Template: {{secret:path}} placeholders anywhere in headers, URL,
or body. Gatehouse substitutes server-side.
Inject shorthand: map header name to secret path. Authorization
headers auto-prefix "Bearer ". Prefix with basic: for HTTP Basic
auth with a user:password value.
"inject": {"Authorization": "api-keys/example"}
"inject": {"Authorization": "basic:infra/opnsense"}
Auto-inject: pass secret paths in an array. Defaults to
Authorization: Bearer <value>. Per-secret metadata header_name
(e.g. X-API-Key) and auth_scheme (empty string disables Bearer)
override this.
"auto_inject": ["api-keys/example"]
Situation, tool
{{SITUATION_TABLE}}
Error decoder
- 401 from Gatehouse: JWT expired. Re-login with role_id +
secret_id from the environment. Retry once.
- 403 on proxy with
requires_approval in the body: this secret
is approval-gated. See Approval-gated secrets above. Request
approval (MCP gatehouse_request_access or HTTP
POST /v1/lease/<path>/request), wait, then retry the original call.
- 403 on proxy otherwise: policy lacks
proxy on that secret path,
OR the target domain isn't in the secret's allowed_domains metadata,
OR the target is a private IP and the secret has allow_private=false.
Check gatehouse_status and gatehouse_list. Don't work around,
tell the operator.
- 403 on lease or checkout: policy lacks
lease on that secret
path. Confirm with gatehouse_list (does the entry appear? what
are its caps?) and tell the operator if lease is missing.
Dynamic secret not found on checkout: you called
gatehouse_checkout against a path that isn't a dynamic config,
or you called gatehouse_lease/gatehouse_get against a dynamic
path. Re-read the entry's kind in gatehouse_list and pick the
matching tool.
- 4xx/5xx from upstream: use the
suggestions in the error
response before retrying.
- Secret not in gatehouse_list: your policy doesn't grant any
usable access to it. Ask the operator to extend your policy. This
applies to static AND dynamic secrets, both appear in the same
list.
- 403 with
requires_approval: the secret needs human approval
before you can use it. Call gatehouse_request_access with a
justification. Wait for the human, don't retry.
- 429 from Gatehouse with
Retry-After: you've hit a rate limit
(per-AppRole or per-secret). Wait the Retry-After value. Do NOT
retry faster. If you keep hitting it, surface to the user that
you're rate-limited rather than spinning.