Secret Sauce lets an AI agent act for a person without ever holding their passwords, cards or identity details. The agent asks; the person approves on their own device; Secret Sauce carries out the action. The agent only ever sees ids and statuses.
Status markers used below:
live works on the hosted site today,
built implemented and tested, run it yourself (the hosted site currently serves only the operator console, so /v1 is not reachable at sauce.hammerlabs.ai yet),
planned not built.
| Caller | Credential | Does |
|---|---|---|
| Your backend | admin key | Registers members, creates agents and their keys, registers webhooks. |
| Your agent | agent key (ssk_…) | Asks members for credentials, checks the answer, starts actions. Sees only the requests it created. |
| Your webhook handler | none (it verifies signatures) | Hears what happened; can deliver the member's link by any channel. |
| The member | a one-time link, then sign-in | Reviews and approves on their own device. Never goes through your agent. |
Every request carries Authorization: Bearer <key>. The agent's identity comes from its key, never from anything it sends. Agent keys are shown once, stored only as hashes, and can be revoked.
Full request and response shapes: openapi.yaml.
Sign in at /console. Owners and admins can, from the checklist:
Connecting your own key management, text-message and browser providers from the console is planned.
# 1. Your backend registers a member. The id is yours; no phone is required.
curl -X POST $SAUCE/v1/members -H "Authorization: Bearer $ADMIN" \
-d '{"external_id":"mary","contact":{"channel":"imessage","address":"+14155550123"}}'
# 2. Your backend creates an agent. Keep the key; it is not shown again.
curl -X POST $SAUCE/v1/agents -H "Authorization: Bearer $ADMIN" -d '{"name":"care-agent"}'
# 3. The agent sees where it may ask, and for what.
curl $SAUCE/v1/destinations -H "Authorization: Bearer $AGENT"
# 4. The agent asks Mary for a kind of credential.
curl -X POST $SAUCE/v1/access-requests -H "Authorization: Bearer $AGENT" \
-d '{"member":"mary","destination":"fakeclinic","kind":"login","purpose":"check my next appointment"}'
# -> {"id":"cap_…","status":"pending","kind":"login","executable":true}
# 5. Mary gets a link and approves. The agent waits (up to 30 s per call).
curl "$SAUCE/v1/access-requests/cap_…?wait=30" -H "Authorization: Bearer $AGENT"
# -> {"id":"cap_…","status":"approved"}
# 6. The agent asks Secret Sauce to act. Retrying with the same key never repeats it.
curl -X POST $SAUCE/v1/executions -H "Authorization: Bearer $AGENT" \
-d '{"access_request_id":"cap_…","operation":"site.login","idempotency_key":"mary-appt-1"}'
curl "$SAUCE/v1/executions/ex_…?wait=30" -H "Authorization: Bearer $AGENT"
# -> {"id":"ex_…","status":"completed","result":{"logged_in":true,"page_title":"…"}}
Instead of polling, subscribe a webhook to access_request.approved and execution.completed.
The agent names a kind; the server decides the fields, their types and the operation. An agent cannot add, remove or retype a field, and a destination only accepts the kinds it was reviewed for. Each field is checked by type (a card number must pass its check digit, an expiry must be a real future month, a date of birth a real past date). A rejected value is refused without saying which field or why.
| kind | Fields | Stronger sign-in | Can be saved | Carried out today |
|---|---|---|---|---|
login | the destination's own form fields | no | yes | yes (site.login) |
card | cardholder_name, card_number, card_expiry, card_cvc, billing_postal_code | yes | all but the security code, which is never kept | no |
identity | full_name, date_of_birth, phone | no | yes | no |
insurance | member_id, group_number, plan_name | no | yes | no |
secret | secret | no | yes | no |
api_token | token | no | yes | no |
"Stronger sign-in" means a passkey rather than a text-message code. Card numbers, security codes and SSNs always need it. Kinds that cannot be carried out yet are still captured, validated and held under the member's consent; asking for an execution on one is refused, and a grant for one operation can never be spent as another.
Sauce does not assume a phone. A member's contact is a channel plus an optional address:
| channel | address | Who delivers the link |
|---|---|---|
sms | E.164 number | Secret Sauce, through the SMS provider you connect. |
whatsapp, imessage, email | number or email | Your webhook handler, on delivery.requested. |
external | none, or an opaque key of yours | Your webhook handler. Sauce holds no contact details. |
Passing "phone" instead of "contact" is shorthand for sms. Subscribe to delivery.requested and send data.link_url to the member through Twilio, iMessage for Business, your own app, or anything else. The link alone grants nothing: the member must still sign in on their device, which your handler cannot do for them. Do not put the link anywhere durable.
Sign-in codes must not pass through your handler either. Member sign-in (a text-message code sent by Secret Sauce, and passkeys) is planned; the dev server has a stand-in sign-in.
curl -X POST $SAUCE/v1/webhooks -H "Authorization: Bearer $ADMIN" \
-d '{"url":"https://hooks.venus.example/sauce",
"events":["access_request.approved","execution.completed","delivery.requested"]}'
# -> {"id":"whe_…","url":"…","events":[…],"secret":"whsec_…"} (the secret is shown once)
Optional agent_id limits an endpoint to one agent's events. Events with no agent (member.*, delivery.requested) reach only endpoints without that filter. There is no wildcard; name each event. Up to 20 endpoints. POST /v1/webhooks/{id}/test sends a ping now; GET /v1/webhooks/{id}/deliveries shows recent attempts.
| event | data |
|---|---|
access_request.approved / .declined / .cancelled | access_request_id, member |
execution.completed / .failed / .needs_review | execution_id, access_request_id, status |
member.withdrawn / .disconnected | member |
delivery.requested | member, access_request_id, link_url, expires_at, and channel / address when Sauce holds them. The only event that carries a link or contact detail. |
ping | empty |
The body is {"id":"evt_…","type":"…","created_at":1700000000,"data":{…}}. Headers: Sauce-Signature, Sauce-Event-Id, Sauce-Event-Type, Sauce-Delivery-Attempt. No event ever carries a credential value, a grant id, or (outside delivery.requested) a phone number or link.
Delivery. Any 2xx is success; the response body is ignored. Otherwise it is retried after 1 min, 5 min, 30 min, 2 h and 12 h, then given up on. Delivery is at least once and not strictly ordered across retries: dedupe on Sauce-Event-Id and treat events as "something changed, ask the API if you need more". Redirects are not followed; each attempt times out after 10 s.
URLs. https only (plain http only on a dev server), no credentials in the URL, and the host must resolve only to public addresses. Loopback, private, link-local (including cloud metadata), shared and reserved ranges, and IPv6 forms that embed them, are refused when you register and again on every delivery.
Sauce-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Verify against the raw bytes you received, before parsing, compare in constant time, and reject timestamps more than 5 minutes off. A header may carry several v1 values during a secret rotation; any match is enough. Both snippets below pass the shared test vector (secret whsec_test_secret, t=1700000000, body {"id":"evt_1","type":"ping"}).
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300, now=None) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"]
if t is None or not t.isdigit() or not sigs:
return False
if abs((now or time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, toleranceSecs = 300, nowSecs = Date.now() / 1000) {
const parts = header.split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!t || !/^\d+$/.test(t) || sigs.length === 0) return false;
if (Math.abs(nowSecs - Number(t)) > toleranceSecs) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Rust callers can use secret_sauce_client::signature::verify, the same code the server signs with.
Agents that speak the Model Context Protocol can use Secret Sauce as tools. secret-sauce-mcp is a stdio MCP server over the same /v1 API, holding one agent key. Configure it in your MCP client (Claude Desktop, Claude Code, …):
{
"mcpServers": {
"secret-sauce": {
"command": "/path/to/secret-sauce-mcp",
"env": { "SAUCE_URL": "https://sauce.example", "SAUCE_API_KEY": "ssk_…" }
}
}
}
| tool | does |
|---|---|
list_destinations | Where you may ask, and which kinds each accepts. |
request_credential | member, destination, kind, purpose: ask the member. Returns a request id. |
check_request | pending / approved / declined; can wait up to 30 s. |
cancel_request | Withdraw a pending request. |
sign_in | Carry out an approved login (once per approval). |
check_execution | running / completed / failed / needs_review. |
No tool accepts or returns a credential value. A remote (HTTP) MCP transport is planned; the server logic is transport-independent.
The agent never holds a credential. The member types it into Secret Sauce's own page, where it is encrypted under a key made for that member, held in your key management service. When the agent starts an execution, a trusted executor on Secret Sauce's side spends the approved grant's single use, checks that the destination is the one the member approved, decrypts the values in memory and puts them into the site itself:
api_token.card, identity and insurance. A payment will need the member to approve a specific amount and payee.Errors are JSON {"error":"<code>"} with deliberately generic codes: unauthenticated (401), denied (403), not_found (404, including other agents' requests), bad_fields (422), request_closed (409), step_up_required (403), internal (500). They never say which field failed validation or whether a link or member exists.