TILLSECRETS · SECRETLESS BROKER

Use a secret without knowing it.

Beta

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

01The handle

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.

reference forms
secretref://<project>/<env>/<KEY>      # canonical
{{ts:<project>/<env>/<KEY>}}           # terse, for env / template files

# e.g.
secretref://infra/prod/NEON_DATABASE_URL

project 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).

02What happens at spend

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:

  1. SSRF — the destination must be a concrete, public HTTPS host (never a private / metadata address).
  2. Resolve — the reference must name a real secret in your org.
  3. 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).
  4. Destination allowlist — the host must be on the secret's own allowlist. Enforced above consent — see below.
  5. Consent — a broker grant must cover (secret, agent, host) and still be usable.
  6. Claim — one use of the grant is claimed atomically; a revoked/exhausted grant fails closed here.
  7. Inject — the plaintext is decrypted in the broker and substituted into only the bytes that leave.
  8. Response-scrub — any echo of the value in the reply is re-tokenised back to its reference before the caller sees it.
  9. Audit — the spend is recorded (reference, destination, agent, approving human) — never the value.
The value is never returned
The plaintext exists only for the microseconds of the outbound request and is never handed back to the agent. For the strongest posture, run the broker on your own machine via the local-agent bridge, so a high-value secret never leaves your host at all.
04Local dev

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
# .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 it
# 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

# Layer several files; for a key in more than one, the later file wins and
# the override is named on stderr (key and file names only):
tilldev secrets exec --env-file .env.refs --env-file .env.pod.refs -- node server.js

# Inspect what a file references (metadata only — no values ever):
tilldev secrets refs .env.refs

Each 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).

On a headless host — a systemd unit, a CI runner — you don't even keep a file of references. The --env <slug> form authenticates with a ts_ service token and pulls the whole environment from the vault into the child process env: the environment is the manifest, so nothing — not the values, not even the list of references — lives in a file on the box.

headless: the environment is the manifest
# Headless host (a systemd unit, a CI runner): there is no local file of
# references to keep — the ENVIRONMENT itself is the manifest. A ts_ service
# token names what may be read; the whole environment's config is pulled into
# the child process env only (never printed, never in your shell).
TILLSECRETS_TOKEN=ts_… TILLSECRETS_URL=https://tilldev.dev \
  tilldev secrets exec --env prod -- node server.js
No .env on disk, ever
Between exec --env-file (references, never values) and exec --env <slug> (nothing on disk at all), a service can boot with its full configuration without a single plaintext secret — or even a secretref:// — ever being written to the machine.
05Strongest posture

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.

spend through the local bridge
# 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&currency=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.

06Curated executors

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.

apply a migration by reference
# 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 migrations/0007_add_orders.sql
Connection-string actions
For a connection secret (the destination host lives inside the value), the secret must have exactly one concrete allow-listed host. The broker checks consent against that host before revealing, then verifies the revealed connection string points at it — a drifted value is refused, unused.

So the first time you spend a given connection secret, expect two fail-closed refusals in a row — each names the gate to open next. Walk this once per secret and every later migrate just runs:

first spend of a connection secret
# The FIRST time you spend a CONNECTION secret (a DATABASE_URL, whose host lives
# INSIDE the value) two one-time gates must be set. Each refusal is fail-closed
# and names the next step, so you can walk it top to bottom:

# 1. Allow-list the DB host on the secret (owner-only for a restricted secret).
#    Skipped → refusal: no_destination_allowlist
tilldev secrets broker allow <secret-id> db.prod.example.com

# 2. Grant consent for the agent to spend the secret against that host.
#    Skipped → refusal: needs_consent
tilldev secrets broker grant create secretref://infra/prod/DATABASE_URL \
  --agent cli --host db.prod.example.com --scope ttl --ttl-minutes 15

# 3. Apply. The connection string is spent INSIDE the broker — it never reaches
#    your terminal — and the broker re-checks the revealed host matches step 1.
tilldev secrets migrate --agent cli \
  --ref secretref://infra/prod/DATABASE_URL --file migrations/0007.sql
Refusal codeWhat it meansFix
no_destination_allowlistThe secret has no single concrete allow-listed host to spend against.broker allow <secret-id> <host>
needs_consentNo usable grant covers (this secret, this agent, this host).broker grant create secretref://… --agent … --host …
destination_blockedThe host is not allowed for this secret, or the revealed connection string points somewhere else.Allow the host, or re-point the secret so they agree. The value was not used.
method_blockedThe host is allowed, but not with this HTTP method.broker allow <secret> <host> --methods …
path_blockedThe host is allowed, but not this path, or substituting the value changed the path.broker allow <secret> <host> --path …
not_entitledYour role can’t read the secret at its tier (7+ needs owner).Have an owner run it, or lower the secret’s or environment’s tier (an owner must do that).
out_of_scopeAn agent token spent a reference outside its ref patterns. Nothing was decrypted.Mint a token with the right --refs. See Agent tokens.
budget_exhaustedAn agent token used its daily budget. It resets at 00:00 UTC.Wait, or mint a token with a higher --budget.
Migrations must be idempotent
migrate splits your file and runs statements one at a time with no per-file transaction, stopping at the first failure. Write them to be safely re-runnable — CREATE … IF NOT EXISTS, ADD COLUMN IF NOT EXISTS — so a partial apply can simply be run again.

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

deploy a Worker by reference
# 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-01

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

POST /api/secrets/broker/spend
# 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&currency=usd",
    "agent_key": "ask-x"
  }'
07Approve now, run later

Presigned calls

Some machines need to make one privileged call and should hold nothing else. A droplet that deletes itself when a job ends is the usual case: giving it the DigitalOcean token would let it delete every droplet you own. A presigned call approves one exact request now. The request can carry secretref:// handles in its URL, headers or body. The machine gets a tsc_ token that can only run that request.

a droplet that deletes itself
# On your machine, while you provision: approve ONE call for later.
# The tag is unique to this job, so the call can only delete this job's droplets.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
  --bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
  --max-uses 1 --expires 6 --quiet > call.token      # tsc_…, shown once

# Pass call.token to the droplet (cloud-init user data, a file you copy in).
# On the droplet, when the job is done:
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" tilldev secrets call

# Or with curl, no CLI needed:
curl -sf -X POST https://tilldev.dev/api/secrets/broker/call \
  -H "Authorization: Bearer $(cat /etc/job/call.token)"
  • Checked twice. At mint time the request must pass the destination rules and your access. When it runs, the broker checks the rules again, and you must still be an admin in the org.
  • Bounded. --max-uses (1–100, default 1), --expires in hours (default 24, up to 90 days) and an optional --not-before. A use is claimed before the secret is substituted, so two racing calls cannot both spend the last use.
  • Nothing to leak. The token cannot read a secret or change the request. The template is stored encrypted and the token only as a hash. The response comes back with any secret value scrubbed.
  • Revocable. tilldev secrets presigned revoke <id>, or from the broker console. Every run is in the spend ledger under the agent presigned:<first 8 of id>.
No consent grant needed
Approving the exact request is the consent. The destination rules still apply, so a presigned call cannot reach a host or path the secret isn't allowed to.

Bound to where it runs

A token can leak: into a log, a support ticket, or the instance metadata any process on the machine can read. Two optional bindings make a leaked token useless anywhere else.

  • Runs from (--from, up to 16): addresses or CIDR ranges, IPv4 or IPv6. A bare address means just that address. The address checked is the one TillDev sees, so a machine behind NAT is seen as the NAT's public address.
  • Machine key (--bind-key): an Ed25519 public key, as an ssh-ed25519 line, ed25519:<base64> or PEM. Each redemption must then carry a proof signed by the private half, so the token is worthless without it. A server's SSH host key works, and nothing new has to be put on the machine.
bound to one machine
# 1. On the machine: its SSH host key already exists and only root can read it.
cat /etc/ssh/ssh_host_ed25519_key.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… root@job-42

# 2. On your machine: presign, bound to that key and the machine's address.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
  --bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
  --from 203.0.113.7 --bind-key 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI…' \
  --quiet > call.token

# 3. On the machine, as root. The token alone is refused; --key signs each run.
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" \
  tilldev secrets call --key /etc/ssh/ssh_host_ed25519_key

# No host key to use? Make a machine key there instead:
tilldev secrets call keygen          # private key to ~/.tilldev/call-key.pem, public line on stdout

The proof, sent as x-tilldev-proof: v1.<unix ms>.<signature>, signs the SHA-256 of the token and the time. It is accepted within two minutes of TillDev's clock, and each one must be newer than the last one accepted, so a copied proof cannot be used again.

signing without the CLI
# Without the CLI (GNU coreutils + OpenSSL 3, key in PEM form, e.g. from keygen):
TOKEN=$(cat call.token)
AT=$(date +%s%3N)
HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)
SIG=$(printf 'tilldev-presign:v1\n%s\n%s' "$HASH" "$AT" |
  openssl pkeyutl -sign -rawin -inkey call-key.pem -in /dev/stdin |
  base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -sf -X POST https://tilldev.dev/api/secrets/broker/call \
  -H "Authorization: Bearer $TOKEN" -H "x-tilldev-proof: v1.$AT.$SIG"

A refused attempt uses nothing. It is counted on the call in the broker console and in tilldev secrets presigned ls, recorded in the audit log as presign.refused with the address it came from, and emailed to whoever presigned the call, at most once an hour per call. If the attempt wasn't yours, revoke the call.

What a binding does not stop
It proves the machine, not the program. Anyone with root on the bound machine can read its host key and run the call. Bind to a key that only the job's user can read if that matters, and keep --max-uses and --expires tight.

Runs by itself

Sometimes the call should happen even if the machine never makes it: the job hung, crashed, or lost its network. A presigned call can carry a timer, and then TillDev runs it itself.

  • At a time (--at): a date such as 2026-10-01T18:00:00Z, or +30m, +2h, +1d from now. The machine can call it off first with tilldev secrets call cancel.
  • Dead-man (--dead-man, 5 minutes to 30 days): TillDev runs it unless the machine checks in at least that often. The first deadline is --at if you give one, otherwise one interval from now. Each tilldev secrets call checkin moves the deadline to one interval from then.
a droplet that deletes itself unless told otherwise
# Delete the droplet at 18:00 unless the job calls it off first.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
  --bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
  --at 2026-10-01T18:00:00Z --expires 12 --quiet > call.token

# On the droplet, to keep it after all:
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" tilldev secrets call cancel

# Dead-man: delete it unless the job checks in at least every 30 minutes.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
  --bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
  --dead-man 30m --expires 48 --quiet > call.token

# On the droplet, from the job's loop or a cron entry:
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" tilldev secrets call checkin

# Or with curl (add x-tilldev-proof if the call is bound to a machine key):
curl -sf -X POST https://tilldev.dev/api/secrets/broker/call/checkin \
  -H "Authorization: Bearer $(cat /etc/job/call.token)"
  • Once. A timer runs the call at most once, within about a minute of its time, even if the request fails. The result is shown on the call in the broker console and in tilldev secrets presigned ls, recorded in the audit log as presign.fire, and emailed to whoever presigned the call.
  • Same checks. The broker re-checks the destination rules, and you must still be an admin in the org. A timer needs a use left: if the machine already spent the last one itself, nothing runs.
  • Held by the token. Check-in and cancel take the same token, address range and machine proof as a run. Once a deadline has passed, the machine can no longer push it back or cancel it; an admin can still revoke the call.
What a timer does not promise
It runs within about a minute of its time, not to the second. It never runs after the call expires, so set --expires past the last deadline you need; a check-in that lands past it says so. If the scheduler is not running when you presign, the presign is refused rather than handing you a timer that might not fire, and a check-in warns you if it stops later.
08From your code

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.

@tillstack/secrets-node
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&currency=usd',
})

if (!res.ok) throw new Error(`refused: ${res.refusal?.code}`)
// res.body is the scrubbed reply — STRIPE_SECRET_KEY never entered this process.
Same client, strongest posture for free
In 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). An agent should use its own agent token instead: the client reads TILLDEV_AGENT_TOKEN first.
09Evidence

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

Refusals are recorded too
A refused spend (needs consent, off-allowlist, not entitled, SSRF, drifted host…) is logged with its reason, so an agent probing for a secret it can't use leaves a trail.