API Reference

API Reference

TAP exposes three groups of endpoints: agent endpoints (authenticated via X-TAP-Key), admin auth endpoints (public), and admin CRUD endpoints (authenticated via Authorization: Bearer <session_token>).


Agent Endpoints

These endpoints are used by AI agents. All except /health require the X-TAP-Key header.

Recommended agent flow:

  1. Fetch /instructions to learn the setup flow.
  2. Ask the user for the one-time agent API key from the dashboard — or, if the user has no TAP account yet, bootstrap one via /onboard/start (below).
  3. Call /agent/bootstrap to verify the key and setup state.
  4. Call /agent/services to discover usable credentials and request templates.
  5. Send requests through /forward.

POST /onboard/start · POST /onboard/poll

Agent-first onboarding — an agent with no API key can bootstrap its human’s account. POST /onboard/start (unauthenticated, rate-limited per IP) returns a claim_url to show the human plus an onboarding_id/poll_secret pair. The human opens the link, signs up or logs in, and explicitly confirms the connection on a consent screen that names the agent and the team it will receive a key for — the claim never happens just from opening the link. The agent then polls POST /onboard/poll with {onboarding_id, poll_secret} until status becomes ready, at which point the response carries a freshly minted Account API key exactly once (subsequent polls return 410 already_delivered). Sessions expire after 30 minutes; claiming requires an owner/admin dashboard session and a deliberate confirmation.

curl -X POST https://proxy.tap.human.tech/onboard/start \
  -H "Content-Type: application/json" -d '{"agent_name": "claude-code"}'

GET /instructions

Public agent setup metadata, served as plain text. Intentionally unauthenticated so a user can give an agent only the TAP proxy URL and the agent can learn where to send the human next. (GET / on the bare proxy domain redirects to the dashboard.)

curl https://proxy.tap.human.tech/instructions

Response (plain text):

# TAP — Tool Authorization Proxy
Proxy: https://proxy.tap.human.tech

Credential proxy for AI agents. Agents forward API calls; TAP injects credentials.

## Quick start

1. GET https://proxy.tap.human.tech/agent/services  (header: X-TAP-Key)
2. POST https://proxy.tap.human.tech/forward

## POST /forward headers

  X-TAP-Key: <api-key>            required
  X-TAP-Target: <upstream URL>    required (full https:// URL)
  X-TAP-Method: GET|POST|…        required (upstream HTTP method)
  X-TAP-Credential: <service>     required (name from /agent/services)
Only documented X-TAP-* headers exist. Others return 400.
Always POST to /forward, even for upstream GETs.
Non-TAP headers forward verbatim. Body goes in HTTP body (no X-TAP-Body).

## Credential placeholders

X-TAP-Credential: <name> is the shortcut: TAP injects the secret using the header and format stored for that credential (Authorization: Bearer {value} unless configured otherwise).

You are not limited to that. Omit X-TAP-Credential and write <CREDENTIAL:name> directly into whatever header the API documents — TAP substitutes the real value there, after policy enforcement:

curl -X POST "$TAP_PROXY_URL/forward" \
  -H "X-TAP-Key: $TAP_API_KEY" \
  -H "X-TAP-Target: https://api.linear.app/graphql" \
  -H "X-TAP-Method: POST" \
  -H "X-Linear-Token: <CREDENTIAL:linear>"

Any header, any format:

Authorization: Basic <CREDENTIAL:langfuse>      # value = base64 of user:pass
X-Vendor-Token: <CREDENTIAL:vendor>
DD-API-KEY: <CREDENTIAL:datadog.api_key>        # one field of a multi-secret credential
DD-APPLICATION-KEY: <CREDENTIAL:datadog.app_key>

Nothing has to be configured first. This is the reason TAP asks a human for so little: the agent has read the vendor’s auth docs and already knows the header name, so requiring a person to look it up and re-type it into a dashboard field would only add a step and a way to be wrong. Set auth_header_format / auth_bindings when you also want the X-TAP-Credential shortcut to work — for well-known vendors TAP’s catalog does that for you.

Placeholders change nothing about enforcement. The secret is still injected by the proxy and never returned to the agent, allowed_hosts still binds which hosts it may be sent to, the SSRF guard still applies, writes are still approval-gated, and responses are still scanned for the secret before they reach you. The approval an operator reviews shows the placeholder, not the value.

Bodies are different. A placeholder in the request body is rejected unless the credential opts into body substitution and the placeholder sits in a recognized auth field (token, api_key, client_secret, …). Host binding can’t help here — a secret pasted into a tweet body would be published by an allowed host — so this restriction stays.

## Requesting a new credential

POST /agent/credential-link with {name, ...} (no secret) to get a
prefilled setup link to hand your user. See below.
...

GET /agent/bootstrap

Authenticated setup check for agents. Use this immediately after the user provides an agent API key. This endpoint does not expose secrets; it only tells the agent whether the key is usable and what to do next.

curl https://proxy.tap.human.tech/agent/bootstrap \
  -H "X-TAP-Key: $MY_KEY"

Ready response:

{
  "protocol": "tap",
  "version": 1,
  "status": "ready",
  "agent_id": "my-agent",
  "team_id": "abc-123",
  "credential_count": 2,
  "dashboard_url": "https://proxy.tap.human.tech/dashboard",
  "services_url": "/agent/services",
  "forward_url": "/forward",
  "logs_url": "/agent/logs",
  "agent_action": "Call /agent/services next, then use /forward with the returned request templates.",
  "safe_to_retry": true
}

If the key is valid but no credentials are assigned, status is needs_credentials and agent_action tells the agent to ask the user to assign credentials in the dashboard.

POST /forward

The core proxy endpoint. Authenticates the agent, evaluates policy, requests approval if needed, injects credentials, forwards the request, and sanitizes the response.

Reference a credential by name. The proxy handles routing and auth injection automatically.

Headers:

HeaderRequiredDescription
X-TAP-KeyyesAgent API key
X-TAP-CredentialyesCredential name (no agent-ID prefix — just the plain name)
X-TAP-TargetyesTarget API URL (or path if relative_target is set)
X-TAP-MethodnoHTTP method to use upstream (default: GET)
X-TAP-Auth-ModenoRare escape hatch for hybrid X/Twitter credentials: auto, bearer, or oauth1. Normal requests should omit it.

Example — auto-approved GET:

curl -X POST https://proxy.tap.human.tech/forward \
  -H "X-TAP-Key: $MY_KEY" \
  -H "X-TAP-Credential: slack" \
  -H "X-TAP-Target: https://slack.com/api/conversations.list" \
  -H "X-TAP-Method: GET"

Example — POST requiring approval:

curl -X POST https://proxy.tap.human.tech/forward \
  -H "X-TAP-Key: $MY_KEY" \
  -H "X-TAP-Credential: slack" \
  -H "X-TAP-Target: https://slack.com/api/chat.postMessage" \
  -H "X-TAP-Method: POST" \
  -H "Content-Type: application/json" \
  -d '{"channel": "C123456", "text": "Hello from my agent"}'

When approval is required, the proxy responds 202 Accepted immediately with a transaction ID, an approval link, and a poll_url. The agent polls GET /agent/approvals/{txn_id} until the status is forwarded, denied, or timed_out (approval window default: 1 hour). Before making any write call, tell the user what you’re about to send; after the 202, show them the approval link. TAP includes a notification_channel field ("dashboard", "agent_reflected", "telegram", or "matrix") in the 202 response so you can tell the user where the approval prompt went.

Example 202 response:

{
  "txn_id": "550e8400-e29b-41d4-a716-446655440000",
  "poll_url": "/agent/approvals/550e8400-e29b-41d4-a716-446655440000",
  "approval_dashboard_url": "https://app.tap.human.tech/dashboard#/approvals",
  "approval_url": "https://app.tap.human.tech/approve/txn/550e8400-...",
  "expires_in": 3600,
  "status": "pending",
  "notification_channel": "agent_reflected",
  "agent_hint": "Approval required. Ask the user to open the dashboard or use the direct approval link. Then poll /agent/approvals/{txn_id} to check status. This now waits for a person. If you are not sure of the request's shape, withdraw it (DELETE /agent/approvals/{txn_id}) and check first: a withdrawn request costs nobody anything, a refused one costs a tap."
}

recent_refusal — when the last approved request on this same credential, method and route was refused by the upstream within the past 30 minutes, the 202 also carries it, and agent_hint leads with it:

{
  "recent_refusal": {
    "at": "2026-06-05T14:02:11Z",
    "http_status": 400,
    "message": "{\"error\":{\"message\":\"amount must be at least 50\"}}"
  },
  "agent_hint": "Your last approved request on this route was refused: '…amount must be at least 50…'. Make sure this one fixes that before the person taps again. Approval required. …"
}

Both exist for the same reason: every retry costs the person another approval. A malformed write spends one tap to be approved and another to approve the fix. While a request is only paused it is still free to take back — so read the refusal, compare it with what you just sent, and withdraw rather than let someone approve the same mistake twice. message is the upstream’s own words, first 200 characters, from a response TAP had already sanitized.

Example — sidecar with relative target:

curl -X POST https://proxy.tap.human.tech/forward \
  -H "X-TAP-Key: $MY_KEY" \
  -H "X-TAP-Credential: telegram" \
  -H "X-TAP-Target: /sendMessage" \
  -H "X-TAP-Method: POST" \
  -H "Content-Type: application/json" \
  -d '{"chat_id": "123", "text": "Hello"}'

Legacy Placeholder Mode

Use <CREDENTIAL:name> placeholders in headers or body. The proxy validates placeholder positions, then substitutes real values after approval.

curl -X POST https://proxy.tap.human.tech/forward \
  -H "X-TAP-Key: $MY_KEY" \
  -H "X-TAP-Target: https://api.openai.com/v1/chat/completions" \
  -H "Authorization: Bearer <CREDENTIAL:openai-key>" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}'

Placeholders are only allowed in auth-related positions. The proxy rejects requests where credentials appear in non-auth positions (tweet text, email body, etc.) to prevent exfiltration.

Error Responses

StatusMeaning
202Approval required — response carries txn_id, approval_url, and poll_url (not an error; poll for the outcome)
400Invalid request (missing headers, unknown X-TAP-* header, placeholder in non-auth position)
401Invalid or missing X-TAP-Key
403Credential not whitelisted for this agent
429Rate limit exceeded
502Upstream API error
503Transient database error — response includes safe_to_retry: true; retry rather than treating it as auth failure

All errors return JSON with an error field. Approval denial and timeout are not HTTP errors on /forward — they surface as "denied" / "timed_out" statuses on the poll endpoint.

Agent UX contract for writes:

  1. Before the call: tell the user what you’re about to post and that they’ll get an approval request.
  2. On 202: show the user the approval_url (or relay agent_hint), then poll poll_url.
  3. On poll status denied: tell the user it was denied and ask whether to modify and retry.
  4. On poll status timed_out: tell the user the approval window expired and ask whether to retry — surface notification_channel from the 202 so they know where the original prompt went.

GET /agent/approvals/:txn_id

Poll the status of a pending approval transaction returned by a 202 from /forward. Requires the same X-TAP-Key that created the transaction.

curl https://proxy.tap.human.tech/agent/approvals/$TXN_ID \
  -H "X-TAP-Key: $MY_KEY"

Response while pending:

{
  "txn_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "created_at": "2026-06-05T14:22:15Z",
  "expires_at": "2026-06-05T14:37:15Z"
}

status is one of pending, forwarded, denied, or timed_out. When the request is approved and forwarded, the response includes the sanitized upstream result:

{
  "txn_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "forwarded",
  "created_at": "2026-06-05T14:22:15Z",
  "expires_at": "2026-06-05T14:37:15Z",
  "response": {
    "status": 200,
    "headers": [["content-type", "application/json"]],
    "body": "{\"ok\":true}",
    "body_encoding": "utf-8",
    "complete": true
  }
}

body_encoding is "utf-8" or "base64" (for binary upstream bodies). Returns 404 for unknown transactions and 403 for transactions created by a different agent.

The embedded approval strip (hosts)

A host that frames TAP’s approve page (allowed origins: frame_guard, default https://*.work.human.tech) can ask for ?embed=strip: a compact row with TAP’s one-line summary, Approve, Deny and the passkey ceremony, and nothing else. It needs no TAP session in the frame, which browsers do not give a cross-site frame anyway: the passkey identifies the approver. Three session-less endpoints back it:

  • GET /approve/txn/:id/strip/summary — credential name, method, target host and TAP’s summary. Not the body or the full URL: a transaction id alone must not reveal them.
  • POST /approve/txn/:id/strip/begin — a challenge covering the passkeys of every team member who may act on this transaction (role, credential assignment, policy approvers).
  • POST /approve/txn/:id/strip/finish?decision=approve|deny — verifies the assertion, resolves the request as the member who owns that passkey. Deny goes through the ceremony too, so holding the id is not enough to deny.

The strip posts {type: "tap:approval", txn_id, outcome, approver} to the embedding origin when done, like the full embed.

When the approver has no passkey (#399). A passkey assertion names the approver by itself; six digits do not, so a host that knows who is in front of it says so. POST /token/approval (as that person: their dashboard session or their Sign-in-with-TAP token) with {"txn_id": "…"} returns {approval_token, expires_at, expires_in, header}. Send it back as X-TAP-Approval-Token on the strip calls.

With it, begin answers for that person — their own passkey challenge, or {"factor":"totp","message":"…"} — and POST /approve/txn/:id/strip/totp?decision=approve|deny takes {"code":"123456"} from their authenticator app. finish additionally requires the assertion to belong to the person the token names. Without it every route behaves exactly as it did.

The token is bound to that person, that one transaction, and five minutes, and dies with the session or token family that minted it. It is not account authority: it cannot forward a request, use or list a credential, read the log, or decide any other transaction, and it is refused everywhere an access token is expected. Do not put your TAP access token in the frame; put this in the fragment, or attach it server-side if you relay the endpoints yourself.

Native passkey in the host’s own page. A host can skip the frame and run the ceremony itself: relay the three strip endpoints from its own origin, pass publicKey from begin to navigator.credentials.get, post the assertion to finish. For the browser to use a TAP passkey (RP ID tap.human.tech) from another origin, that origin must be listed in TAP’s related origins: https://tap.human.tech/.well-known/webauthn (served by the site) and WEBAUTHN_ADDITIONAL_ORIGINS (what the proxy accepts). Both live in the agentsec repo: site/public/.well-known/webauthn and deploy/azure/env.*.json. Chrome 128+ and Safari 18+ honour related origins; older browsers fall back to the strip in a frame.

DELETE /agent/approvals/:txn_id

Withdraw a still-pending request — the agent takes it back before a person is troubled with it. Requires the same X-TAP-Key (or the same MCP bearer) that created the transaction.

curl -X DELETE https://proxy.tap.human.tech/agent/approvals/$TXN_ID \
  -H "X-TAP-Key: $MY_KEY"
{ "status": "withdrawn", "txn_id": "550e8400-…" }

Nothing is sent upstream and no approval is spent. The request leaves the approvers’ inbox, and GET /agent/approvals/:txn_id then reports withdrawn.

Why it exists. Every approval an agent asks for spends a person’s attention, and a malformed write spends it twice — once to approve it and once to approve the corrected retry. A withdrawal costs nobody anything. If you are unsure of a paused request’s shape, withdraw it, check, and send it again.

Only while pending: once a human has decided, or the request has expired, the answer is 409 not_pending (the decision is theirs). 403 for another agent’s transaction, 404 for an unknown one.

POST /agent/approvals/:txn_id/decide

Human-only — an agent cannot call this. It is listed here because it acts on the same transaction id, and because an agent that tries to approve its own request should find out from the reference rather than from a 403.

A person’s host app resolves one of that person’s own pending approvals, using an OAuth access token carrying the tap:approve scope.

curl -X POST https://app.tap.human.tech/agent/approvals/$TXN_ID/decide \
  -H "Authorization: Bearer $USER_OAUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"decision": "approve", "note": "expected — I asked for this"}'
{ "status": "approved", "approver": "sam@example.com", "channel": "host" }

decision is "approve" or "deny"; note is optional and is recorded in the audit trail.

X-TAP-Key is never accepted here — including the key of the agent that raised the very request. Presenting one is 403 agent_key_not_accepted. A bearer that was not granted tap:approve — such as the tap:full token the agent side of the same connection uses — is 401 invalid_token. An agent must not approve itself.

Approving is honoured only when the credential’s policy does not require a passkey. When it does:

{
  "error": "passkey_required",
  "message": "This credential requires a passkey. Open the approval page and complete the ceremony there.",
  "approval_url": "https://app.tap.human.tech/approve/txn/550e8400-…"
}

That is a 409; embed approval_url and let the person complete the ceremony there. Denying is always honoured, passkey policy or not — refusing is never the privileged action.

Other responses: 403 not_a_member (the token’s owner has left that team), 410 session_expired (already resolved, or expired), 409 already_resolved. Team ownership, credential assignment and allowed_approvers are checked exactly as they are for a dashboard click.

When the person approved the request but the upstream refused it (4xx/5xx), the response also carries an agent_hint telling the agent to read the service’s message, fix the request, and record what worked with PATCH /agent/credentials/:name — because every retry costs the person another approval.

Driving an approval in code

Embedding TAP in an SDK or non-conversational agent? Write one service-agnostic helper that forwards and polls — the target, method, body, and credential are all arguments, so the same function reaches any credential in the account (discover them via /agent/services). Don’t hardcode a function per service.

import json, time, urllib.request, urllib.error
 
def tap_forward(base, key, target, method, body, cred_headers, timeout=300):
    """Route any upstream call through TAP and poll to a terminal decision. Fail-closed."""
    headers = {"X-TAP-Key": key, "X-TAP-Target": target, "X-TAP-Method": method.upper(),
               "Content-Type": "application/json", **cred_headers}  # never real secrets
    code, text = _http("POST", f"{base}/forward", headers, body.encode() if body else None)
    parsed = _json(text)
    if code != 202:                       # terminal already (auto-approved or error)
        return {"ok": 200 <= code < 300, "status": code, "body": parsed}
    txn = parsed.get("txn_id")
    deadline = time.time() + timeout
    while time.time() < deadline:
        time.sleep(2)
        _, ptext = _http("GET", f"{base}/agent/approvals/{txn}", {"X-TAP-Key": key}, None)
        pr = _json(ptext)
        state = pr.get("status")
        if state == "forwarded":
            up = pr.get("response") or {}
            ok = isinstance(up.get("status"), int) and 200 <= up["status"] < 300
            return {"ok": ok, "decision": "forwarded", "status": up.get("status"), "body": up.get("body")}
        if state in ("denied", "timed_out", "error"):
            return {"ok": False, "decision": state}
    return {"ok": False, "decision": "timeout"}
 
def _http(method, url, headers, data):
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return r.status, r.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")
    except urllib.error.URLError as e:
        return 0, json.dumps({"error": str(e.reason)})
 
def _json(text):
    try:
        return json.loads(text)
    except ValueError:
        return text

Two things to get right when real money or production is on the line:

  • Fail closed. Any non-forwarded outcome — denied, timed out, network error, malformed response — must be a failure that does not execute the action. The helper above returns ok: true only on a forwarded status with a 2xx upstream response.
  • Make retries idempotent. If your poll deadline elapses while the human hasn’t decided, you return a failure — but they can still approve later and the action will execute. Where the upstream supports an idempotency key, set a stable one per logical action so a retry can’t double-submit.

GET /agent/services

Returns available credentials for the authenticated agent. This is the agent’s source of truth for request shape — use the returned request_template rather than guessing URL patterns or header names.

Single key (most common):

curl https://proxy.tap.human.tech/agent/services \
  -H "X-TAP-Key: $MY_KEY"

Multiple keys — access credentials from several TAP accounts at once:

Pass a comma-separated list of API keys to merge credentials from multiple agents (whether they’re in the same team or different teams) into one response:

curl https://proxy.tap.human.tech/agent/services \
  -H "X-TAP-Key: $PERSONAL_KEY,$COMPANY_KEY"

In multi-key mode each credential name is prefixed with the agent ID it belongs to (e.g. personal-agent.github, company-agent.slack) so there are no collisions even if two accounts have a credential with the same name. The response includes an accounts map with key_index telling you which key to use per account when calling /forward — keys themselves are never returned.

Single-key response:

{
  "agent_id": "my-agent",
  "home_team_id": "abc-123",
  "services": {
    "slack": {
      "description": "Slack API",
      "target_shape": "full_url",
      "target_base": "https://slack.com/api",
      "approval": {
        "default_decision": "pauses_for_human",
        "url_match": "path_prefix_or_exact_host_path_prefix_with_star_segments",
        "rules": [
          { "target": "*", "methods": ["GET", "HEAD"], "decision": "proceeds_immediately" },
          { "target": "*", "methods": ["POST", "PUT", "PATCH", "DELETE"], "decision": "pauses_for_human" }
        ],
        "note": "decision describes what TAP does, not what the agent must do. 'pauses_for_human' means TAP automatically routes the request to a human approver — the agent does not ask for approval itself, it just expects the call to block until a human responds. Rules are evaluated top to bottom; url_override rules win over method rules. A target starting with '/' matches the request URL path prefix; any other target requires an exact host match before matching the path prefix. In paths, '*' matches exactly one non-empty segment. Query strings and fragments do not participate."
      },
      "request_template": {
        "method": "POST",
        "url": "https://proxy.tap.human.tech/forward",
        "headers": {
          "X-TAP-Key": "$TAP_API_KEY",
          "X-TAP-Credential": "slack",
          "X-TAP-Target": "https://slack.com/api/<path>",
          "X-TAP-Method": "GET|POST|PUT|PATCH|DELETE"
        }
      }
    }
  }
}

Multi-key response (two keys sent, personal-agent at index 0, company-agent at index 1):

{
  "accounts": {
    "personal-agent": { "agent_id": "personal-agent", "key_index": 0 },
    "company-agent":  { "agent_id": "company-agent",  "key_index": 1 }
  },
  "services": {
    "personal-agent.github": { "account": "personal-agent", "description": "GitHub", ... },
    "company-agent.github":  { "account": "company-agent",  "description": "GitHub", ... },
    "company-agent.slack":   { "account": "company-agent",  "description": "Slack",  ... }
  }
}

When calling /forward for a multi-key credential, use the key at key_index from the key list you sent, and strip the agent-ID prefix from the credential name:

# For company-agent.slack (key_index 1 → $COMPANY_KEY), credential name is just "slack"
curl -X POST https://proxy.tap.human.tech/forward \
  -H "X-TAP-Key: $COMPANY_KEY" \
  -H "X-TAP-Credential: slack" \
  -H "X-TAP-Target: https://slack.com/api/chat.postMessage" \
  -H "X-TAP-Method: POST" \
  -d '{"channel": "C123", "text": "hello"}'

Internal details (credential values, sidecar URLs, connector internals) are never returned. Agents should copy the request_template for a service, fill in a valid X-TAP-Target, and put write payloads in the HTTP body.

GET /agent/logs

Returns recent audit log entries for the authenticated agent.

curl "https://proxy.tap.human.tech/agent/logs?limit=5" \
  -H "X-TAP-Key: $MY_KEY"

Query parameters:

ParamDefaultMaxDescription
limit20100Number of entries to return
sincenonen/aOnly entries at or after this RFC3339 timestamp, e.g. 2026-09-11T15:17:00Z
untilnonen/aOnly entries at or before this RFC3339 timestamp
credentialnonen/aOnly entries that used this credential. Matched whole, so github does not match github-bot
methodnonen/aOnly this HTTP method
writes_onlyfalsen/atrue keeps only the requests that changed something upstream: everything that is not GET, HEAD or OPTIONS
cursornonen/aResume after a previous page. Pass back the next_cursor you were given, unchanged

Pass nothing and you get what you always got: the 20 most recent entries, oldest first. A bad parameter is a 400 that names the parameter, never a silently empty page.

next_cursor is how you read past limit. It is an opaque string: a page that ends with one has more entries behind it, and a page with next_cursor: null is the last one. Page boundaries hold even while new requests are being logged, so nothing is repeated or skipped between calls.

# Every write on the `slack` credential since yesterday, 50 at a time
curl "https://proxy.tap.human.tech/agent/logs?credential=slack&writes_only=true&since=2026-09-10T00:00:00Z&limit=50" \
  -H "X-TAP-Key: $MY_KEY"

This endpoint is self-scoped and stays that way: an API key sees its own requests and no other key’s, whatever the query string asks for. For the view across every key in the team, use the dashboard’s Request Log below.

Response:

{
  "agent_id": "my-agent",
  "count": 1,
  "next_cursor": null,
  "entries": [
    {
      "request_id": "550e8400-e29b-41d4-a716-446655440000",
      "agent_id": "my-agent",
      "credential_names": ["slack"],
      "target_url": "https://slack.com/api/conversations.list",
      "method": "GET",
      "approval_status": null,
      "upstream_status": 200,
      "total_latency_ms": 145,
      "approval_latency_ms": null,
      "upstream_latency_ms": 120,
      "response_sanitized": false,
      "request_headers": [["X-TAP-Credential", "slack"]],
      "request_body": null,
      "request_body_truncated": false,
      "policy_reason": "auto_approve_method",
      "require_passkey": false,
      "approver_identity": null,
      "timestamp": "2026-03-30T14:22:15.123Z"
    }
  ]
}

Fields:

FieldDescription
approval_statusnull (no approval was required — see policy_reason), "pending", "approved", "denied", or "timeout"
request_headers / request_bodyThe request exactly as you sent it, before credential substitution — placeholders like <CREDENTIAL:name> or the X-TAP-Credential header are intact. The real injected secret is never stored or returned here. request_body is null when there was no body, the body wasn’t valid UTF-8, or it exceeded the 16KB audit cap (see request_body_truncated)
policy_reasonWhich policy rule produced the decision, e.g. auto_approve_url, auto_approve_method, require_approval_method, require_approval_default, or a team-default reason like team_gated_default_safe_method
require_passkeyWhether this credential’s policy required passkey-strength approval
approver_identityWho resolved the approval, when known — a TAP account email for dashboard/agent-reflected/passkey approvals, or a messaging-platform identity (Telegram/Matrix user id) for messaging-channel approvals. null when auto-approved or unresolved

A request that required human approval (write methods, by default) now appears here too, once resolved — including denials and timeouts, not just successful forwards.

The Request Log (dashboard)

/agent/logs answers “what did this key do”. The person who owns the credential has a different question, because they hold several keys and did not make the requests themselves. So the dashboard has Request Log, a view over every request every key in the team sent.

Each row shows the time, which API key, which credential, the method, the target host and path, the upstream status, the approval status and who approved it. Expanding a row shows the full target URL, the policy reason, the latency breakdown, the recorded request headers, a preview of the request body, and a link to the approval transaction where there was one. Filter by credential, API key, time range, method, status, or writes only, and page back with Load more. Each credential’s row on the Credentials page has a View requests button that opens the log already narrowed to it.

Two things the view will not do. Headers are shown as the agent sent them, which is before credential substitution, so a <CREDENTIAL:name> placeholder appears intact. That is the answer to “which credential did this call reference”. Any other value in a header that carries authentication is masked, because nothing stops an agent putting a key it holds itself into one. And the scope follows the API Keys page: an owner or admin reads the whole team, a Member reads only the keys they created.

It reads GET /team/audit with a dashboard session, taking the same since, until, credential, method, writes_only, cursor and limit parameters as /agent/logs plus agent and status, and returning {entries, count, next_cursor}. The default page is 50 and the ceiling is 500.

GET /agent/config

Returns the credential list for the authenticated agent.

curl https://proxy.tap.human.tech/agent/config \
  -H "X-TAP-Key: $MY_KEY"

Response:

{
  "agent_id": "my-agent",
  "credentials": [
    {
      "name": "slack",
      "description": "Slack API",
      "api_base": "https://slack.com/api"
    }
  ]
}

POST /agent/proposals

Propose a policy change for a human workspace manager to review. The proposal is inert — it never grants authority by itself; a manager approves or denies it in the dashboard (approval is passkey-gated). Capped at 20 pending proposals per agent (429 beyond that).

curl -X POST https://proxy.tap.human.tech/agent/proposals \
  -H "X-TAP-Key: $MY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "proposal_type": "policy_change",
    "payload": {
      "credential_name": "slack",
      "auto_approve_urls": ["/conversations.list"],
      "require_approval_urls": []
    }
  }'

Poll the decision with GET /agent/proposals/{id} (team-scoped, same X-TAP-Key). auto_approve_urls and require_approval_urls use structural URL matching: leading / matches the URL path prefix, host-qualified values require an exact host, and * matches one path segment. require_approval_urls are safety overrides and win over broader auto URL patterns.

POST /agent/credentials

The primary credential-setup endpoint. One door, two branches, selected by whether the request body carries value — not by preference, but by where the secret lives:

SituationBranch
The secret already exists on this machine (.env, ~/.aws, an env var)Enroll — send value
A human still has to generate or fetch the secret (vendor dashboard, 1Password, a teammate)Link — omit value
Google / Microsoft — there is no pasteable secret, only a consent flowLink (mandatory)
Signing keysGenerate in-proxy (dashboard Signing Key template, or POST /app/users/{ext_id}/keys) — never enroll

This is enrollment, not creation: the agent can enroll a credential it already has access to. If an agent can cat .env, it already holds that key — enrollment moves an existing secret under TAP’s enforcement (host binding, approval gating, audit), it does not hand the agent anything new. It is never “the agent creates credentials.”

Only an agent key owned by a workspace manager may enroll or replace a credential — a key owned by an approver-tier member gets 403 enrollment_not_allowed. Keys with no owner (provisioned directly by an admin) are unaffected.

Branch 1 — enroll (value present)

Pipe the secret from its source — an env var, a file, a secrets manager — so it never enters the agent’s own context (never read it into a variable, print it, or echo it “to check the format” first). TAP cannot verify this; only the agent knows whether it happened.

curl -X POST https://proxy.tap.human.tech/agent/credentials \
  -H "X-TAP-Key: $MY_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg v "$STRIPE_SECRET_KEY" '{
    name: "stripe",
    value: $v,
    allowed_hosts: ["api.stripe.com"],
    description: "Stripe live key"
  }')"

allowed_hosts is required on this branch — non-negotiable, no warn-only mode. An enrolled secret-bearing credential left unbound is exactly the destination-host exfiltration TAP otherwise guards against. Omitting it (or sending only blank entries) is 400 allowed_hosts_required.

Auth scheme. By default TAP injects the value as Authorization: Bearer {value}. If the upstream needs something else (Langfuse and several other APIs need HTTP Basic, for example), pass auth_header_format — e.g. "Basic {value}", where value is the base64 of user:pass you compute before enrolling, or "{value}" for a bare token with no scheme at all. This is not guesswork: the success response’s auth field (below) states exactly what TAP applied, so a 401 can be triaged as “wrong key” vs. “wrong scheme” without a second round trip. auth_bindings is a different mechanism — use it only for multi-field secrets (Datadog, AWS), not to change a single-secret credential’s scheme.

Multi-secret values (Datadog, AWS-shaped APIs, anything needing two or more independent secrets) use a JSON object for value plus non-empty auth_bindings mapping each field to the header it authenticates:

curl -X POST https://proxy.tap.human.tech/agent/credentials \
  -H "X-TAP-Key: $MY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "datadog",
    "value": {"api_key": "dd-api-key-here", "app_key": "dd-app-key-here"},
    "allowed_hosts": ["api.datadoghq.com"],
    "auth_bindings": [
      {"header": "DD-API-KEY", "format": "{value.api_key}"},
      {"header": "DD-APPLICATION-KEY", "format": "{value.app_key}"}
    ]
  }'

auth_bindings is optional. Supply it and plain X-TAP-Credential: <name> injects every field into its header. Omit it and the credential is used with <CREDENTIAL:name.field> placeholders instead — a normal way to use it, not a broken state. (An unresolvable binding, naming a field the value doesn’t have, is still rejected: that’s a typo that would otherwise send a literal {value.x} upstream in an auth header.)

Success response (200):

{
  "status": "enrolled",
  "name": "stripe",
  "allowed_hosts": ["api.stripe.com"],
  "fingerprint": "…4x9",
  "verify": {"status": "ok", "http_status": 200, "probe_url": "https://api.stripe.com/v1/account"},
  "policy": "approval_required_all",
  "auth": {
    "header": "Authorization",
    "format": "Bearer {value}",
    "source": "default",
    "override": "TAP applies this scheme. If your API needs a different one, re-enroll the same name with auth_header_format — e.g. \"Basic {value}\" for HTTP Basic (value = the base64 of user:pass), or \"{value}\" for a bare token. Use auth_bindings only for multi-field secrets."
  },
  "agent_action": "Before you continue, answer honestly for your user: did this key ever enter your context — did you read, print, or echo it rather than piping it straight from the file or env var? If it did, tell your user now and recommend they rotate it. TAP cannot detect this; only you know.",
  "next": "This credential requires approval on every request, including reads. If that is too tight for the task, propose a policy via POST /agent/proposals — your user approves it with their passkey. Never assume a looser policy; ask."
}

fingerprint is the secret’s last 4 characters (or {field1, field2} for a multi-secret value) — enough for a human to recognize it against the vendor’s own dashboard, and the only place any part of the value is ever echoed back. The raw value never appears in the response, the audit log, or /agent/logs.

auth is derived from what TAP actually stored, never guessed: source is "default" (no auth_header_format/auth_bindings — the fallback Bearer {value} applies), "auth_header_format" (an explicit format was set — header/format echo it back), or "auth_bindings" (a multi-secret credential — headers lists the bound header names instead of a single header/format pair).

Every enrolled credential defaults to approval-required on every method, including GETs — this does not weaken TAP’s approval gate, it is the gate. A creation-time dialog can’t teach a human anything (“agent enrolled a key” tells them nothing); the human’s real decision point is the credential’s first use, where they can judge “the agent wants to GET this from api.stripe.com.” Loosening that default is a policy_change proposal via POST /agent/proposals — passkey-approved, never assumed.

If verify comes back auth_rejected, status is enrolled_unverified and a warning field explains the credential was saved but the upstream rejected it (401/403). The value being wrong (truncated, test key instead of live) is one hypothesis — the warning also names the exact scheme TAP sent (matching auth, above) and points at auth_header_format, since a wrong scheme is at least as common a cause and, unlike a bad key, is fixable without asking the user for anything. Re-enroll the same name with the corrected value and/or auth_header_format (see replacement, below). An inconclusive verify (no curated probe for that vendor) still saves the credential; the agent’s first real /forward call resolves it to ok or auth_rejected automatically.

Rejections specific to this branch:

Statuserror_codeCause
400allowed_hosts_requiredallowed_hosts missing, empty, or all-blank
400multi_secret_unboundObject value with empty/unresolvable auth_bindings
400signing_bundle_rejectedvalue parses as a signing bundle ({algorithm, private_key}) or a PEM private key
400template_takes_no_valuetemplate ("google", "microsoft", or "signing") sent alongside value
400unsupported_connectorconnector (other than "direct"), api_base, or relative_target sent alongside value — enrollment is direct-secret-only
400invalid_namename doesn’t match ^[a-z0-9-]{1,64}$
400invalid_valuevalue is empty, or not a string/object (e.g. a number or array)
403credential_limit_reachedTeam’s credential count is at its plan limit
403enrollment_not_allowedThe API key belongs to an approver-tier member, not a workspace manager
409credential_existsSee replacement rule, below

A signing bundle is refused rather than silently accepted because /sign’s guarantee is that the private key never enters over HTTP at all, not only that it never leaves TAP again — accepting one over the wire degrades a promise TAP can otherwise keep exactly. If such a key really was sitting in a file on this machine, treat it as compromised and generate a fresh one (dashboard Signing Key template, or POST /app/users/{ext_id}/keys) rather than enrolling the old one.

Replacing an existing name — 409 by default. Re-enrolling a name that already exists returns 409 credential_exists unless both hold: this same agent enrolled it originally, and it has never successfully authenticated (no ok verify, no successful forward). That narrow exception exists so the single likeliest failure — a wrong or truncated paste — is fixable without a trip to the dashboard. Once a credential has ever proven itself, that proof is permanent: a later failure (revocation, expiry) never reopens replacement, and a human-created credential is never replaceable by an agent at all. The response explains why and where to go instead:

{
  "error_code": "credential_exists",
  "error": "A credential named 'stripe' already exists and has authenticated successfully. Agents cannot replace it.",
  "detail": "If the stored key is wrong or compromised, your user rotates it in the dashboard (Credentials → stripe → Replace secret). To use a different key alongside this one, enroll it under a new name."
}

Unchanged from the legacy /agent/credential-link behavior: the agent supplies metadata only (name, description, API base, allowed_hosts) and gets back a prefilled dashboard link to hand its user. Use this when the secret still needs to be generated or fetched by a human, or for Google/Microsoft (no pasteable secret exists — only a consent flow).

curl -X POST https://proxy.tap.human.tech/agent/credentials \
  -H "X-TAP-Key: $MY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "digitalocean", "description": "DigitalOcean PAT", "api_base": "https://api.digitalocean.com", "allowed_hosts": ["api.digitalocean.com"]}'

Prefilling allowed_hosts opens the dashboard modal with the destination-host exfiltration binding already populated, so a secret-bearing credential is bound to its real upstream host rather than left unbound. It’s still metadata only — the human pastes the secret and saves.

OAuth services — no secret to paste. For Google (Gmail, Calendar, Drive, Sheets) or Microsoft (Outlook, Graph), pass template: "google" or "microsoft" plus scope bundle ids in scopes (Google: gmail, gmail-readonly, calendar, drive, sheets, contacts, tasks, search-console, google-ads, and the admin-only workspace-admin, admin-reports, google-cloud; Microsoft: mail-read, mail-send, calendar-read, calendar-readwrite, contacts-read, and for OneDrive files-read, files-readwrite). The link opens the guided consent flow with those permissions prechecked — the human reviews and clicks Connect, one click from the provider’s OAuth screen. Raw scope URLs are rejected; prefer the least-privilege bundle that covers the task. Sending value alongside a template is rejected — see template_takes_no_value above.

curl -X POST https://proxy.tap.human.tech/agent/credentials \
  -H "X-TAP-Key: $MY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "mom-gmail", "description": "Read email", "template": "google", "scopes": ["gmail-readonly"]}'

For APIs that authenticate via a non-Authorization header (e.g. x-api-key), you may prefill auth_bindings so plain X-TAP-Credential works — but you don’t have to, since you can put <CREDENTIAL:name> in that header yourself at request time. Each binding is { "header": "Header-Name", "format": "..." }, and format must use the {value} placeholder — {value} for a single-secret credential, or {value.<field>} to reference one field of a multi-field credential. Do not use {secret}; it is rejected by the dashboard validator (Auth binding must include {value} or {value.<key>}).

curl -X POST https://proxy.tap.human.tech/agent/credentials \
  -H "X-TAP-Key: $MY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "pangram-text", "description": "Pangram AI text detection", "api_base": "https://text.external-api.pangram.com", "allowed_hosts": ["text.external-api.pangram.com"], "auth_bindings": [{"header": "x-api-key", "format": "{value}"}]}'

Response:

{
  "create_url": "https://app.tap.human.tech/dashboard?prefill_credential=...#/credentials",
  "enroll_command": "read -rsp 'Paste the secret for stripe: ' TAP_SECRET; echo\njq -n --arg v \"$TAP_SECRET\" '{\"allowed_hosts\":[\"api.stripe.com\"],\"name\":\"stripe\",\"value\":$v}' \\\n  | curl -sS -X POST \"$TAP_PROXY_URL/agent/credentials\" -H \"X-TAP-Key: $TAP_AGENT_KEY\" -H 'Content-Type: application/json' --data-binary @-\nunset TAP_SECRET",
  "agent_action": "Offer your user both, and let them pick: send create_url if they'd rather paste the secret into a browser form, or enroll_command if they'd rather run one line in their own terminal. Either way the secret goes from them to TAP without passing through you. Retry your request once they confirm it's added.",
  "next": "Hand your user create_url (or enroll_command, if they prefer a terminal to a browser — the verify step is the same after either). When they say they're done: GET /agent/config until 'stripe' appears, then POST /agent/credentials/stripe/verify. Only retry your original request once verify returns ok.",
  "secret_handling": "The invariant is that the secret never enters your context or the chat transcript — not that it must travel by any one route. …"
}

Two doors out of one call. create_url sends the human to the dashboard; enroll_command is the branch-1 enroll call prefilled from the body you just sent, for the human to run in their own terminal. They differ only in whether that person would rather use a browser or a shell, so offer both rather than choosing for them — the link wins on a phone, the command wins over SSH or on a headless box. Neither routes the secret through the agent.

Pass enroll_command on verbatim: read -rs keeps the key out of argv (and so out of ps) and out of shell history, jq --arg handles JSON escaping, and the body is piped rather than echoed. If your harness lets a user run shell from inside the conversation and echoes it back (Claude Code’s ! prefix, for one), tell them to use a separate terminal — inline, the secret lands in the transcript and the invariant is broken while the call still appears to succeed.

enroll_command is null when it could not work: template: "google" | "microsoft" (consent flow, no pasteable secret), template: "signing" (keys are generated in-proxy, and enroll rejects them), or a request with no allowed_hosts — required on the enroll branch, so the command would be certain to 400. When it’s null, create_url is the only door.

name must be 1-64 characters, lowercase alphanumeric plus hyphens (^[a-z0-9-]{1,64}$) — the same constraint the dashboard’s own create-form input enforces. A name outside that charset (e.g. google:workspace-admin, notion/api) is rejected with 400 rather than handed to the human as a prefilled link their browser would refuse to submit.

When a /forward call references a credential that doesn’t exist, the error response includes a credential_link_url only if the attempted name is itself valid by that same rule (it’s built from whatever the agent sent as X-TAP-Credential, which isn’t pre-validated) — otherwise agent_action explains why no link was generated. That same error also names the enroll door directly, so an agent that already has the secret doesn’t have to rediscover it from these docs.

POST /agent/credential-link still works, as a plain alias for this branch only — same link-building code and the same response body, enroll_command included, so an agent on the older name is never told about fewer doors than one on the newer. It still hard-rejects any value in the body (400) rather than silently routing it anywhere. If you have the secret in hand, use POST /agent/credentials with value instead; hitting /agent/credential-link and getting the “must not include value” rejection does not mean enrollment isn’t supported — it means you’re at the metadata-only door.

GET /agent/connectors

What kinds of credential a host app’s “Connect an account” card can offer on this deployment. Same auth as /agent/services: an X-TAP-Key, or an MCP OAuth bearer.

Two things a card cannot know on its own: which sign-in providers this server holds OAuth client configuration for, and which permission bundles it may ask for. Both are server facts, so the server states them instead of every host keeping a copy that drifts.

curl https://proxy.tap.human.tech/agent/connectors -H "X-TAP-Key: $MY_KEY"
{
  "connectors": [
    {
      "id": "api_key",
      "kind": "secret",
      "label": "API key",
      "available": true,
      "fields": [{"name": "value", "label": "API key", "secret": true, "required": true}],
      "enroll": {"method": "POST", "path": "/agent/credentials", "mode": "direct"}
    },
    {
      "id": "google",
      "kind": "oauth",
      "label": "Google",
      "available": true,
      "scopes": [{"id": "gmail", "label": "Gmail (full mailbox access)"}],
      "default_scopes": ["gmail", "calendar", "drive", "sheets"],
      "start": {"method": "POST", "path": "/agent/credentials/oauth/google/start", "auth": "mcp_oauth_bearer"}
    }
  ]
}

available is false when the server has no OAuth client id for that provider; the entry then carries unavailable_reason naming the missing variable (never a value). A kind: "secret" connector lists the fields a card renders and an enroll block saying how to submit them — mode: "direct" posts them as value, mode: "link" (OAuth 2.0 client credentials) posts without value and returns a create_url the person opens.

Every kind: "oauth" entry has the same shape whether the provider is hand-written (Google, Microsoft) or a row in the provider table (QuickBooks Online, Facebook, Instagram) — a card cannot tell the two apart, which is the point. Sign-in Providers lists the table and how a provider is added by config.

POST /agent/credentials/oauth/:provider/start

Start a consent flow from a host app’s connect card. provider is any id GET /agent/connectors lists with kind: "oauth" — google, microsoft, or a provider-table id such as quickbooks.

Two security rules, both hard:

  • A person’s token only. Authenticated by the signed-in person’s MCP OAuth access token (Authorization: Bearer …, scope tap:full). An X-TAP-Key is refused with 403 {"error": "person_token_required"} — consent is a human act, and an agent that could mint a consent URL for an account nobody asked to connect is exactly the authority TAP withholds. No separate passkey step is asked for: the bearer was minted behind one.
  • The member’s role decides. Same gate as the dashboard — owners and admins. Anyone else gets 403 {"error": "not_allowed", "message": "Ask a workspace admin to add this account."}.
curl -X POST https://proxy.tap.human.tech/agent/credentials/oauth/google/start \
  -H "Authorization: Bearer $PERSON_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "google-work", "scopes": ["gmail", "calendar"], "return_url": "https://box.work.human.tech/oauth-done"}'
{"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "state_expires_in": 600}

Open auth_url in a popup. return_url must be https (plain http is allowed only on localhost, for development) and must sit on an origin this server already lets a host app embed the approval page from — TAP_FRAME_ANCESTORS, default https://*.work.human.tech — or on the server’s own dashboard origin. Anything else is 400 {"error": "invalid_return_url"}. Without that rule the endpoint would be an open redirect.

The credential is created on the person’s own team, exactly as the dashboard flow creates it. When consent finishes, TAP sends the browser back to return_url with:

OutcomeQuery
Success?name=<credential name>&status=ok
Failure?name=<credential name>&status=error&reason=<reason>

reason is one of the existing callback reasons — access_denied (the person cancelled on the provider’s consent screen), scopes_declined, token_exchange_failed, no_refresh_token, missing_connection_param (the provider returned no company/tenant id the API needs — see Sign-in Providers), credential_exists, server_error. The host page closes the popup and tells its opener.

An expired host flow returns to return_url with status=error&reason=expired_state only when TAP atomically consumes the stored state and confirms it belongs to that provider. The consumed row supplies the already-validated host URL; a callback-supplied return_url is never trusted. Missing, unknown, replayed, or provider-mismatched state has no trusted host destination and remains dashboard-bound. Expired dashboard and app-mediated flows also remain dashboard-bound rather than borrowing their stored partner URL.

PATCH /agent/credentials/:name

Record what the agent learned about making this credential’s API accept a request. Same auth as /agent/services (X-TAP-Key or an MCP OAuth bearer); the credential must be one the calling key can already reach, in its own team. A name in another team is a 404, never a 403.

curl -X PATCH https://proxy.tap.human.tech/agent/credentials/acme-bank \
  -H "X-TAP-Key: $MY_KEY" -H "Content-Type: application/json" \
  -d '{"notes": "Send `to` as an array. Every POST needs idempotency_key."}'

Notes are agent-authored and separate from description, which a human wrote and this endpoint never touches. Plain text, at most 600 characters (control characters are stripped, whitespace collapsed); over the limit is 400 {"error_code": "notes_too_long"}. {"notes": null} clears the note.

A note is visible to the team: it appears in that credential’s /agent/services entry alongside description, and read-only in the dashboard credential view under “Agent notes”, where a workspace manager can clear it. The write is auto-approved — it is metadata, not an upstream call — but it lands in the same audit trail as any other credential change, with the author and the text.

POST /agent/credentials/:name/verify

Fire a live probe against the credential’s upstream to confirm it actually authenticates — agent-auth version of the workspace-manager verify endpoint, scoped to the calling agent’s own whitelist. Runs automatically as part of enroll (branch 1, above); call it directly to re-check a credential set up via the link branch, or after a human rotates a secret in the dashboard.

curl -X POST https://proxy.tap.human.tech/agent/credentials/stripe/verify \
  -H "X-TAP-Key: $MY_KEY"

Response:

{"status": "ok", "http_status": 200, "probe_url": "https://api.stripe.com/v1/account"}

status is one of:

StatusMeaning
okAuthenticated successfully against a curated probe
auth_rejectedA curated probe returned 401/403, or its vendor-specific response failed the authenticated-success check
inconclusiveA curated probe returned a non-transient response that proves neither successful authentication nor rejected authentication
upstream_errorTransient failure (429/5xx/timeout) — retried a couple of times server-side first; this is the vendor, not necessarily the key
no_probeNo curated probe exists for this vendor (agent calls never send a generic or custom probe)
no_valueNo value is stored for this credential yet
config_errorBroken bindings (e.g. multi_secret_unbound)
unsupportedNon-direct connector (OAuth/sidecar credentials aren’t probed this way)

The workspace-manager /team/credentials/:name/verify surface may additionally run a generic fallback or an explicit custom probe. Every non-transient generic HTTP result is inconclusive — including 401/403, because an unknown service may expect another authentication scheme. A custom probe’s 401/403 is auth_rejected; its other non-transient results are inconclusive.

The upstream response body is never returned — only status. The probe target is always chosen by TAP from within the credential’s own allowed_hosts, never agent-supplied, so verify itself can never be steered to exfiltrate the secret. GET /agent/config returning a credential’s name only proves a row was written — it proves nothing about a truncated paste or a test key used by mistake; call verify (or enroll with value, which verifies inline) before relying on a newly added credential.

Both enroll and verify count against the agent’s configured rate limit, plus a built-in cap of 60 credential operations/hour that applies even to an agent with no configured limit.

GET /health

Health check endpoint. No authentication required.

curl https://proxy.tap.human.tech/health

Returns 200 OK with {"status": "ok", "build": {"sha": "...", "version": "..."}} when the proxy is running.


Admin Auth Endpoints

These endpoints handle team signup, email verification, login, and logout. No authentication is required for signup and login.

POST /signup

Create a new team and admin account.

Body:

{
  "team_name": "my-team",
  "email": "admin@example.com",
  "password": "a-strong-password"
}

Validation:

  • team_name: 3-64 characters, lowercase alphanumeric with hyphens
  • email: must contain @ and .
  • password: minimum 8 characters

Response (201):

{
  "team_id": "550e8400-e29b-41d4-a716-446655440000",
  "team_name": "my-team",
  "admin_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "message": "Verification email sent. Check your inbox."
}

curl example:

curl -X POST https://proxy.tap.human.tech/signup \
  -H "Content-Type: application/json" \
  -d '{"team_name": "my-team", "email": "admin@example.com", "password": "my-password"}'

POST /verify-email

Verify email address with the 6-digit code sent during signup.

Body:

{
  "email": "admin@example.com",
  "code": "123456"
}

Response (200):

{"verified": true}

POST /login

Authenticate with email and password. Returns a session token valid for 24 hours.

Body:

{
  "email": "admin@example.com",
  "password": "my-password"
}

Response (200):

{
  "session_token": "a1b2c3d4e5f6...",
  "admin_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "team_id": "550e8400-e29b-41d4-a716-446655440000",
  "expires_at": "2026-04-03T14:00:00Z"
}

curl example:

curl -X POST https://proxy.tap.human.tech/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "my-password"}'

POST /logout

Invalidate the current session.

Header: Authorization: Bearer <session_token>

curl -X POST https://proxy.tap.human.tech/logout \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response (200):

{"logged_out": true}

Admin CRUD Endpoints

Dashboard/team management endpoints require Authorization: Bearer <session_token>. Operations are scoped to the active team in that session.

Credentials

Credential values are write-only. They are never returned by any API endpoint.

GET /team/credentials

List credentials for your team. Owners and admins see all credentials. Approvers see only credentials assigned to them on the Team page.

curl https://proxy.tap.human.tech/team/credentials \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "credentials": [
    {
      "name": "slack",
      "description": "Slack API",
      "connector": "direct",
      "api_base": "https://slack.com/api",
      "relative_target": false,
      "auth_header_format": null,
      "auth_bindings": [],
      "value_hint": "xo***en"
    }
  ]
}

value_hint is a masked preview (first and last two characters) confirming a value is stored — the value itself is never returned.

POST /team/credentials

Create a new credential. Owners and admins only.

Body:

{
  "name": "slack",
  "description": "Slack API",
  "connector": "direct",
  "api_base": "https://slack.com/api",
  "relative_target": false,
  "auth_header_format": "Bearer {value}",
  "auth_bindings": [],
  "value": "xoxb-your-slack-token"
}
FieldRequiredDefaultDescription
nameyesCredential identifier
descriptionyesHuman-readable description
connectorno"direct""direct" (API key injection) or "sidecar" (external service)
api_basenoBase URL for the target API or sidecar
relative_targetnofalseIf true, X-TAP-Target is a path appended to api_base
auth_header_formatnoFormat string for the Authorization header (e.g., "Bearer {value}"). Optional — see below.
auth_bindingsno[]Explicit auth header bindings, { "header": "Header-Name", "format": "{value}" }. Optional — see below.
valuenoThe secret credential value (write-only, never returned)

Both auth fields are optional, and most credentials should omit them. They exist only to make the one-header shortcut (X-TAP-Credential: <name>) send the right thing. An agent can always ignore them and put <CREDENTIAL:name> in whatever header and format the API documents — see Credential placeholders. Setting them is a convenience, never a prerequisite, and for well-known vendors TAP’s own catalog fills them in so nobody types anything.

Multi-secret credentials (Datadog, and others)

APIs that need two or more independent secrets in one request are modeled as one credential whose value is a JSON object — value accepts either a string (single secret) or an object (multi-secret):

{
  "name": "datadog",
  "description": "Datadog API + application keys",
  "connector": "direct",
  "auth_bindings": [
    { "header": "DD-API-KEY", "format": "{value.api_key}" },
    { "header": "DD-APPLICATION-KEY", "format": "{value.app_key}" }
  ],
  "value": { "api_key": "dd-api-key-here", "app_key": "dd-app-key-here" }
}

With the bindings above, unified mode (X-TAP-Credential: datadog) injects both headers automatically. In placeholder mode, reference each field with the dotted form:

curl -X POST https://proxy.tap.human.tech/forward \
  -H "X-TAP-Key: $MY_KEY" \
  -H "X-TAP-Target: https://api.datadoghq.com/api/v1/validate" \
  -H "X-TAP-Method: GET" \
  -H "DD-API-KEY: <CREDENTIAL:datadog.api_key>" \
  -H "DD-APPLICATION-KEY: <CREDENTIAL:datadog.app_key>"

Field references (<CREDENTIAL:name.field>) may appear in any header — the agent is explicitly choosing the wiring — but body position rules still apply, and a bare <CREDENTIAL:name> against a multi-secret credential is never substituted. The dashboard equivalent is the Multi-secret option on a Custom credential — see Credential Setup.

curl example:

curl -X POST https://proxy.tap.human.tech/team/credentials \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "slack",
    "description": "Slack API",
    "connector": "direct",
    "api_base": "https://slack.com/api",
    "value": "xoxb-your-slack-token"
  }'

Response (201):

{"name": "slack", "created": true}

DELETE /team/credentials/:name

Delete a credential.

curl -X DELETE https://proxy.tap.human.tech/team/credentials/slack \
  -H "Authorization: Bearer $SESSION_TOKEN"

Agents

GET /team/agents

List API keys. Owners and admins see all team API keys. Approvers see only API keys they created themselves.

curl https://proxy.tap.human.tech/team/agents \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "agents": [
    {
      "id": "research-bot",
      "description": "Research assistant",
      "enabled": true,
      "rate_limit_per_hour": 100,
      "created_at": "2026-04-01T10:00:00Z"
    }
  ]
}

POST /team/agents

Create an API key. The key is returned once and cannot be retrieved again. Owners and admins can assign roles and direct credentials. Approvers can create keys only for themselves, using direct credentials already assigned to them; approver-created keys cannot use roles.

Body:

{
  "id": "research-bot",
  "description": "Research assistant",
  "roles": ["reader"],
  "credentials": ["slack"],
  "rate_limit_per_hour": 100
}
FieldRequiredDescription
idyesAgent identifier
descriptionnoHuman-readable description
rolesnoList of role names to assign. Owners/admins only
credentialsnoList of credential names for direct access. Approvers may include only credentials assigned to them
rate_limit_per_hournoMax requests per hour (null = unlimited)

curl example:

curl -X POST https://proxy.tap.human.tech/team/agents \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "research-bot",
    "description": "Research assistant",
    "roles": ["reader"],
    "credentials": ["slack"],
    "rate_limit_per_hour": 100
  }'

Response (201):

{
  "id": "research-bot",
  "api_key": "a1b2c3d4e5f6...",
  "message": "Save this API key — it will not be shown again."
}

GET /team/agents/:id

Get agent details including effective credentials (union of role credentials and direct assignments).

curl https://proxy.tap.human.tech/team/agents/research-bot \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "id": "research-bot",
  "description": "Research assistant",
  "enabled": true,
  "rate_limit_per_hour": 100,
  "created_at": "2026-04-01T10:00:00Z",
  "effective_credentials": ["github", "slack"]
}

DELETE /team/agents/:id

curl -X DELETE https://proxy.tap.human.tech/team/agents/research-bot \
  -H "Authorization: Bearer $SESSION_TOKEN"

POST /team/agents/:id/enable

Re-enable a disabled agent.

curl -X POST https://proxy.tap.human.tech/team/agents/research-bot/enable \
  -H "Authorization: Bearer $SESSION_TOKEN"

POST /team/agents/:id/disable

Disable an agent. All requests from this agent will be rejected until re-enabled.

curl -X POST https://proxy.tap.human.tech/team/agents/research-bot/disable \
  -H "Authorization: Bearer $SESSION_TOKEN"

Roles

Roles provide RBAC for credential access. An agent’s effective permissions are the union of all its roles’ credentials plus its direct credential assignments.

GET /team/roles

curl https://proxy.tap.human.tech/team/roles \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "roles": [
    {
      "name": "reader",
      "description": "Read-only API access",
      "rate_limit_per_hour": 50
    }
  ]
}

POST /team/roles

Create a role with optional initial credentials. Owners and admins only.

Body:

{
  "name": "reader",
  "description": "Read-only API access",
  "credentials": ["slack", "github"],
  "rate_limit_per_hour": 50
}
curl -X POST https://proxy.tap.human.tech/team/roles \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "reader", "description": "Read-only access", "credentials": ["slack", "github"]}'

DELETE /team/roles/:name

Delete a role. Cascades — removes the role from all agents.

curl -X DELETE https://proxy.tap.human.tech/team/roles/reader \
  -H "Authorization: Bearer $SESSION_TOKEN"

Policies

Policies control per-credential approval behavior. See Policies & Approval for details on evaluation order.

GET /team/policies/:cred_name

curl https://proxy.tap.human.tech/team/policies/slack \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "credential": "slack",
  "auto_approve_methods": ["GET", "HEAD"],
  "require_approval_methods": ["POST", "PUT", "DELETE"],
  "auto_approve_urls": ["/conversations.list", "/users.list"],
  "require_approval_urls": [],
  "allowed_approvers": ["alice@example.com"],
  "approval_channel": "dashboard",
  "telegram_chat_id": "-100123456789",
  "matrix_room_id": null,
  "matrix_allowed_approvers": [],
  "require_passkey": false,
  "min_approvals": 1
}

Returns 404 if no policy is set for the credential.

PUT /team/policies/:cred_name

Set or update the policy for a credential.

Body:

{
  "auto_approve_methods": ["GET", "HEAD"],
  "require_approval_methods": ["POST", "PUT", "DELETE"],
  "auto_approve_urls": ["/conversations.list"],
  "require_approval_urls": [],
  "allowed_approvers": ["alice@example.com"],
  "approval_channel": "telegram",
  "telegram_chat_id": "-100123456789",
  "matrix_room_id": "!roomid:matrix.org",
  "matrix_allowed_approvers": ["@user:matrix.org"],
  "require_passkey": false,
  "min_approvals": 1
}
FieldDescription
auto_approve_methodsHTTP methods that skip approval
require_approval_methodsHTTP methods that require human approval
auto_approve_urlsStructural URL patterns that skip approval regardless of method. Values starting with / match the URL path prefix; other values require an exact host before matching the path prefix. In paths, * matches one non-empty segment.
require_approval_urlsStructural URL patterns that always require approval. These are safety overrides evaluated before broader auto_approve_urls patterns and use the same * segment syntax. On update, omit to preserve the existing safety overrides; send [] to clear them.
allowed_approversTeam member emails allowed to approve. Empty = anyone on the team
approval_channelOptional per-credential channel override: dashboard, agent_reflected, telegram, or matrix
telegram_chat_idOverride the default Telegram chat for this credential
matrix_room_idOverride the default Matrix room for this credential
matrix_allowed_approversMatrix-specific approver list (@user:server format)
require_passkeyRequire a second factor at approval: the approver’s passkey (Face ID / YubiKey), or the code from their authenticator app when their account has no passkey. A session alone is never enough.
min_approvalsNumber of approvers who must approve before proceeding (default 1)

curl example:

curl -X PUT https://proxy.tap.human.tech/team/policies/slack \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "auto_approve_methods": ["GET"],
    "require_approval_methods": ["POST", "PUT", "DELETE"],
    "auto_approve_urls": ["/conversations.list", "/users.list"],
    "require_approval_urls": []
  }'

Team

GET /team

Get your team information.

curl https://proxy.tap.human.tech/team \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "my-team",
  "created_at": "2026-04-01T00:00:00Z"
}

Notification Channels

Configure where approval requests are sent for your team.

GET /team/notification-channels

curl https://proxy.tap.human.tech/team/notification-channels \
  -H "Authorization: Bearer $SESSION_TOKEN"

Response:

{
  "notification_channels": [
    {
      "id": "ch-1",
      "channel_type": "telegram",
      "name": "ops-channel",
      "config": {"chat_id": "-100123456789"},
      "enabled": true,
      "created_at": "2026-04-01T00:00:00Z"
    }
  ]
}

POST /team/notification-channels

Create a notification channel. Supports telegram, matrix, dashboard, and agent_reflected.

dashboard and agent_reflected do not require external config:

{
  "channel_type": "dashboard",
  "name": "dashboard",
  "config": {}
}
{
  "channel_type": "agent_reflected",
  "name": "agent-reflected",
  "config": {}
}

Telegram:

{
  "channel_type": "telegram",
  "name": "ops-channel",
  "config": {"chat_id": "-100123456789"}
}
curl -X POST https://proxy.tap.human.tech/team/notification-channels \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channel_type": "telegram", "name": "ops-channel", "config": {"chat_id": "-100123456789"}}'

Matrix:

{
  "channel_type": "matrix",
  "name": "ops-matrix",
  "config": {
    "room_id": "!yourroom:matrix.org",
    "homeserver_url": "https://matrix.org"
  }
}

homeserver_url is optional if the server has a Matrix bot configured with a default homeserver. Do not include access_token — that is a global secret managed by the server.

POST /team/notification-channels/:name/default

Make a channel the team default — it is tried first when routing approval requests.

curl -X POST https://proxy.tap.human.tech/team/notification-channels/ops-channel/default \
  -H "Authorization: Bearer $SESSION_TOKEN"

POST /team/notification-channels/:name/test

Send a test message through a channel to verify it’s wired up.

curl -X POST https://proxy.tap.human.tech/team/notification-channels/ops-channel/test \
  -H "Authorization: Bearer $SESSION_TOKEN"

DELETE /team/notification-channels/:name

curl -X DELETE https://proxy.tap.human.tech/team/notification-channels/ops-channel \
  -H "Authorization: Bearer $SESSION_TOKEN"