Secret Sauce: integration guide

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.

Who talks to what

CallerCredentialDoes
Your backendadmin keyRegisters members, creates agents and their keys, registers webhooks.
Your agentagent key (ssk_…)Asks members for credentials, checks the answer, starts actions. Sees only the requests it created.
Your webhook handlernone (it verifies signatures)Hears what happened; can deliver the member's link by any channel.
The membera one-time link, then sign-inReviews 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.

Setting up in the console live

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.

Quickstart built

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

Kinds of credential built

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.

kindFieldsStronger sign-inCan be savedCarried out today
loginthe destination's own form fieldsnoyesyes (site.login)
cardcardholder_name, card_number, card_expiry, card_cvc, billing_postal_codeyesall but the security code, which is never keptno
identityfull_name, date_of_birth, phonenoyesno
insurancemember_id, group_number, plan_namenoyesno
secretsecretnoyesno
api_tokentokennoyesno

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

Reaching a member built

Sauce does not assume a phone. A member's contact is a channel plus an optional address:

channeladdressWho delivers the link
smsE.164 numberSecret Sauce, through the SMS provider you connect.
whatsapp, imessage, emailnumber or emailYour webhook handler, on delivery.requested.
externalnone, or an opaque key of yoursYour 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.

Webhooks built

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.

eventdata
access_request.approved / .declined / .cancelledaccess_request_id, member
execution.completed / .failed / .needs_reviewexecution_id, access_request_id, status
member.withdrawn / .disconnectedmember
delivery.requestedmember, access_request_id, link_url, expires_at, and channel / address when Sauce holds them. The only event that carries a link or contact detail.
pingempty

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.

Verifying a webhook built

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.

MCP for agents built

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_…" }
    }
  }
}
tooldoes
list_destinationsWhere you may ask, and which kinds each accepts.
request_credentialmember, destination, kind, purpose: ask the member. Returns a request id.
check_requestpending / approved / declined; can wait up to 30 s.
cancel_requestWithdraw a pending request.
sign_inCarry out an approved login (once per approval).
check_executionrunning / 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.

How credentials are used

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:

Errors

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.

Not built yet planned