Use a secret without knowing it.
BetaUsing a secret with an AI agent usually means putting the plaintext where the agent can read it — a chat message, a file it opens — so the value enters the model's context, the transcript, and every log. The broker inverts that: the agent works with a stable, non-sensitive reference; TillSecrets materialises the real value only at the outbound egress boundary, with your consent, against an allow-listed destination, and audits every use. The value never touches the model, the chat, the agent's memory, or a log.
References
A reference is a URI-shaped handle that is safe to appear anywhere — chat, prompt, file, PR, log — because it is not the value and reveals nothing beyond the name (and the name is already governed by the Secrets-layout egress rules). It resolves only inside the broker, and only for an entitled actor with consent; it is inert everywhere else.
secretref://<project>/<env>/<KEY> # canonical
{{ts:<project>/<env>/<KEY>}} # terse, for env / template files
# e.g.
secretref://infra/prod/NEON_DATABASE_URLproject and env are your TillSecrets slugs; KEY is the secret's env-var name. Only static secrets are brokerable today (dynamic leases resolve through their own leasing flow).
The fail-closed chain
Every spend — a generic HTTP request or a catalog action — passes the same checks, in order, and stops closed at the first failure:
- SSRF — the destination must be a concrete, public HTTPS host (never a private / metadata address).
- Resolve — the reference must name a real secret in your org.
- Entitlement — the acting human must be able to read the secret at its sensitivity tier (a restricted, prod-grade secret needs owner; others need admin).
- Destination allowlist — the host must be on the secret's own allowlist. Enforced above consent — see below.
- Consent — a broker grant must cover
(secret, agent, host)and still be usable. - Claim — one use of the grant is claimed atomically; a revoked/exhausted grant fails closed here.
- Inject — the plaintext is decrypted in the broker and substituted into only the bytes that leave.
- Response-scrub — any echo of the value in the reply is re-tokenised back to its reference before the caller sees it.
- Audit — the spend is recorded (reference, destination, agent, approving human) — never the value.
Consent & the destination allowlist
Two independent controls gate every spend. The destination allowlist is a per-secret list of the only hosts the value may ever reach (an exact host like api.stripe.com, or a wildcard like *.stripe.com). It is enforced above consent: even a consenting — or socially-engineered — user cannot send the key to an off-list host. A consent grant then authorises one agent to spend one secret against one allowed host, at a scope you choose.
# 1. Allow-list the ONLY hosts this secret may ever be spent against.
# Enforced ABOVE consent: a tricked/consenting user still can't redirect it.
tilldev secrets broker allow <secret-id> api.stripe.com
# 2. Consent: agent X may use the secret against that host — once / session / ttl.
tilldev secrets broker grant create \
--secret <secret-id> --agent ask-x --host api.stripe.com \
--scope ttl --ttl-minutes 60
# See consent + every spend (never a value):
tilldev secrets broker grants
tilldev secrets broker spends| Scope | Meaning |
|---|---|
once | A single spend, then spent. |
session | Any number of spends within one bound session id. |
ttl | Any number of spends until the expiry you set (--ttl-minutes). |
The first spend of a new (reference → host) pair is where a human grants consent — approve once, for the session, or TTL-boxed — like granting an OAuth scope. After that the agent spends the reference repeatedly with no re-paste and no per-call re-approval. Revocation (broker grant revoke) takes effect immediately: an in-flight spend against a just-revoked grant fails closed.
secrets exec
On your own machine, secrets exec materialises references into a child process's real env at launch — never printed, never in your shell's env. Keep a .env.refs of references (safe to commit) instead of a .env of secrets:
# .env.refs — values are references, not secrets. Safe to commit.
DATABASE_URL=secretref://infra/prod/NEON_DATABASE_URL
STRIPE_SECRET_KEY=secretref://payments/prod/STRIPE_SECRET_KEY# Run a command with the references materialised into ITS process env only —
# never printed, never in your shell's env. Fail-closed: if a reference can't
# resolve (or you're not entitled), the command does not run.
tilldev secrets exec --env-file .env.refs -- npm run migrate
# Inspect what a file references (metadata only — no values ever):
tilldev secrets refs .env.refsEach materialisation is entitlement-checked and audited to a local destination. If any reference can't resolve, the command does not run (pass --allow-partial to override).
The local bridge
The broker's safest home is the local-agent bridge. When the bridge runs your agent, it stands up a loopback detokenising endpoint on 127.0.0.1 and injects two variables into the agent's environment: TILLDEV_BROKER_URL and a per-run TILLDEV_BROKER_TOKEN. The agent posts an outbound request that carries a reference to $TILLDEV_BROKER_URL/spend — the same shape as the cloud endpoint — and the real value is injected into the outbound bytes on your machine.
# On the local-agent bridge, the shim runs on 127.0.0.1 and the agent's env
# carries TILLDEV_BROKER_URL + TILLDEV_BROKER_TOKEN. The agent posts an outbound
# request that carries a reference to the LOCAL endpoint — same shape as the cloud
# /broker/spend — and the value is injected + spent on THIS machine.
curl -X POST "$TILLDEV_BROKER_URL/spend" \
-H "authorization: Bearer $TILLDEV_BROKER_TOKEN" \
-H "content-type: application/json" \
-d '{
"method": "POST",
"url": "https://api.stripe.com/v1/charges",
"headers": { "Authorization": "Bearer secretref://payments/prod/STRIPE_SECRET_KEY" },
"body": "amount=2000¤cy=usd"
}'
# The value never enters the agent's context, and the request body never leaves
# your host — only the destination host + the reference reach TillDev to authorise.Only the destination host and the non-sensitive references reach TillDev, which runs the full chain and authorises the spend as bridge:<device> (audited as a bridge.spend). The plaintext and your actual API traffic never transit the cloud — the leakage surface for that class of task drops to near-zero. The shim is on by default; disable it per device with TILLFORGE_BRIDGE_BROKER=off.
Catalog actions
Some privileged actions don't map to a single HTTP request — a database migration, for instance, speaks the Postgres wire protocol. A catalog action is a curated executor that performs the action itself and returns a result (rows applied, a deployment id) — never the materialised secret. It runs the same authorize → consent → allowlist → audit chain.
# Catalog action: apply a migration to a database named ONLY by reference.
# The connection string never reaches your terminal or the agent.
tilldev secrets migrate \
--ref secretref://infra/prod/NEON_DATABASE_URL \
--file packages/db/drizzle/0043_tillsecrets_broker.sqlThe second flagship action deploys a Cloudflare Worker: the agent supplies the script and names the API token only by reference. Because the destination is a real, fixed HTTPS host, it runs the standard chain against api.cloudflare.com (which must be on the token secret's allowlist), uploads the Worker, and returns the deployment result — the token is spent, never seen.
# Catalog action: deploy a Cloudflare Worker with the API token named ONLY by
# reference. The token never reaches your terminal or the agent — the broker
# reveals it against api.cloudflare.com (which must be on the token's allowlist),
# uploads the Worker, and returns the deployment result.
tilldev secrets deploy-worker \
--ref secretref://infra/prod/CLOUDFLARE_API_TOKEN \
--account 0123456789abcdef0123456789abcdef \
--name my-worker --file dist/worker.js --compat-date 2026-07-01Anything outside the catalog falls back to the generic broker: post the outbound request to /api/secrets/broker/spend with the reference in a header or the body, and the broker injects the value toward the one allowed host.
# The server-side detokenising proxy. Post an outbound request that carries a
# reference; the broker injects the real value into ONLY the bytes that leave and
# returns a scrubbed response. The value never comes back to the caller.
curl -X POST https://tilldev.dev/api/secrets/broker/spend \
--cookie "$SESSION" -H "content-type: application/json" \
-d '{
"method": "POST",
"url": "https://api.stripe.com/v1/charges",
"headers": { "Authorization": "Bearer secretref://payments/prod/STRIPE_SECRET_KEY" },
"body": "amount=2000¤cy=usd",
"agent_key": "ask-x"
}'The broker SDK
The @tillstack/secrets-node and @tillstack/secrets-edge packages ship a first-class broker client so application and agent code can spend references without hand-rolling the wire calls. secretref() builds validated handles; broker.fetch() returns the scrubbed response or a structured refusal — the value never enters your process.
import { createBroker, secretref } from '@tillstack/secrets-node'
// auto mode: uses the LOCAL bridge when running under the TillForge daemon
// (value never leaves the machine), else the cloud broker with your token.
const broker = createBroker({ agentKey: 'billing-job' })
const res = await broker.fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { authorization: `Bearer ${secretref('payments', 'prod', 'STRIPE_SECRET_KEY')}` },
body: 'amount=2000¤cy=usd',
})
if (!res.ok) throw new Error(`refused: ${res.refusal?.code}`)
// res.body is the scrubbed reply — STRIPE_SECRET_KEY never entered this process.auto mode the Node client routes through the local bridge whenever it runs under the TillForge daemon (the daemon injects TILLDEV_BROKER_URL), so the value is spent on your machine and never reaches the cloud — no code change. Off the bridge, it uses the cloud broker with a session or tp_ API key plus an agent identity (never the ts_ service token — the broker acts on a human's entitlement, not a machine's).Every spend is audited
The broker keeps an append-only spend ledger: one row per attempt — successful or refused — with the reference, destination, agent identity and approving human, but never the value. Review it with tilldev secrets broker spends or in the Secrets console. It is the compliance backbone of “the agent used this key, against this host, at this time, with this consent.”