AI agents
Give every AI agent an identity of its own instead of your API key. An agent is a key, not a name: you register only its public key, it signs in with a private_key_jwt assertion and gets a 10-minute token bound to that key with DPoP, so a token copied out of a log or a prompt is useless on its own. Each agent has an accountable owner, the action types it may ask your users to approve, and a review date.
tq_ key can do everything its scopes allow; an agent token can only do what the agent is for. If you must give a script a key, create one with narrow scopes (for example login:*): anything else answers 403 insufficient_scope.| Piece | Rule |
|---|---|
client_assertion | JWT (RFC 7523) signed by one of the agent's keys (ES256 or EdDSA, kid in the header): iss = sub = agent id, aud = …/agents/token, at most 5 minutes, a jti used once. |
DPoP | A proof JWT (RFC 9449) on the token request and on every call: htm, htu, a fresh iat, a unique jti, and on calls ath (the token's hash). The token only works with the key it was bound to. |
access_token | Opaque, 10 minutes, scopes agent:read approvals:request approvals:read. No refresh token: the agent asks again (the SDKs do). |
| State | Meaning |
|---|---|
blocked: suspended | Suspended by you (API or dashboard); resume to let it act. |
statusReason: owner_left | Its owner left the organization: give it a new owner (PATCH ownerEmail) or revoke it. |
blocked: review_overdue | Its review date passed: check it is still needed, then renew (PATCH reviewInDays). |
blocked: workspace_paused | The kill switch is on: every agent of the workspace is stopped. |
blocked: revoked | Revoked for good; the record stays for your audit trail. |
Every agent call re-checks its agent, so suspending, revoking, removing a key or the kill switch (PUT /agents/kill-switch) also ends tokens issued before. In the dashboard (Developers → AI agents) stopping is instant; letting an agent act again is approved on the admin's phone. Every sign-in and change is a log row (AGENT_*) and a webhook for the ones you may act on. A SIEM reads them all with GET /agents/events?after=… (oldest first; keep next; tq.agents.listEvents in every SDK), and an Action on the agent.approval trigger may refuse, step up or ask for a passkey before anything reaches the phone.
The agent sends POST /agents/approvals with typed authorizationDetails, never a free-text summary. Touchque checks every field and draws the rows on the phone in its own words: the agent's name first, then the details, then the agent's reason marked unverified. Text with invisible, control or direction-changing characters is refused. The request goes through the same checks as a sign-in (freeze, push limit, risk engine, Actions, Vishing Shield, case code); the phone needs Face ID or its hardware key, and its signature covers exactly those rows. With presence: interactive (default) the person types the number your conversation shows; background has no number and is never allowed for a critical action type.
| type | Fields |
|---|---|
payment_initiation | instructedAmount { currency, amount }, creditorName, creditorAccount { iban | accountNumber }, remittanceInformationUnstructured IBANs must pass their check digits. |
purchase | amount { currency, amount }, merchant, item |
data_access | resource, actions [read, write, delete, export, share], records, destination |
account_change | setting, from, to |
message_send | channel [email, sms, chat, social, other], recipients[], subject |
generic | fields [{ label, value }] (1-4) Your own labels; prefer a typed entry. |
| status | Meaning |
|---|---|
pending | Waiting for the phone; show challengeCode in the conversation. |
approved | approvalToken is in the answer (5 minutes; ask again for a fresh one while usable). |
provisional | Approved during a phone call (Vishing Shield): no token until the phone confirms it after the call. |
rejected · blocked · revoked | The person said no, the risk engine refused, or the approval was taken back. |
awaiting_second | Four eyes: the person approved, the second approver has not yet (secondApprover, secondChallengeCode). No token until both did. |
expired · stale · redeemed | Not answered in time, approved more than 15 minutes ago, or already used. |
typ: touchque-approval+jwt, keys at GET /agents/jwks.json: sub (the person), act.sub (the agent), authorization_details, cnf.jkt (the agent's key), details and proof (the phone's hardware signature over them), 5 minutes. Your executor redeems it once (POST /agents/approvals/redeem) right before acting and compares the details with what it is about to do; the SDKs do both in redeemApproval(token, expect) and refuse a mismatch before anything is used.Each agent has a policy (PUT /agents/{agentId}/policy, or its page in the dashboard), checked before anything reaches a phone; the person's own push limit, freeze and the risk engine still apply after it. In the dashboard a tighter policy saves at once, a looser one waits for the admin's phone.
| Field | Rule |
|---|---|
requestsPer10Min · requestsPerUserPer10Min | 60 · 5. Every request of the agent, and the requests to one person (push fatigue). |
amountPerRequest · amountPerDay | { "TRY": "1000.00" }: the most one approval, and 24 hours of pending and approved ones, may move. |
background | non_critical (default) or never. A critical action type is never asked in the background. |
newRecipients · recipientAllowlist | ask (default) or deny an account, merchant or address this person never approved for this agent; an allowlist of IBANs or accounts refuses any other. |
fourEyes | { approvers, above?, types? }: after the person, a second person (never the same one, never the agent's owner) approves on their own phone, for these action types or amounts over above. |
requirePasskeyAbove | { "TRY": "10000.00" }: over it the person approves with a passkey on your page (the approval says requiresPasskey; your page calls passkeys.approveLogin({ requestId: approvalId }) from @touchque/web, which goes through your relay's /passkey/login routes), never on the phone or with a mandate; a background request gets 409 interactive_required. |
PUT /agents/workspace-policy | The same fields for the whole workspace: every agent's floor. An agent's own policy can only make each field stricter; a field the workspace leaves out sets no floor. |
A mandate turns pushes a person would tap through into one deliberate decision: the agent asks once (POST /agents/mandates) for one action type, at most N requests, a total and an optional per-request cap in one currency, optional recipients, until a time (5 minutes to 7 days). The phone shows every limit and the person approves with the number. Requests within it (mandateId on POST /agents/approvals) are approved without a push and taken from it atomically, so a looping or hijacked agent can never go beyond what the person read. Mandates cover payments and purchases only, and never stand in for four eyes. The person, the agent, you or an admin can end one at any time.
| error | Meaning |
|---|---|
blocked_by_person | The person said "this wasn't me", said someone on the phone told them to, or rejected it three times in 30 minutes. Only an admin lifts it, on their phone. |
amount_over_limit · daily_amount_over_limit | Over amountPerRequest or amountPerDay. |
recipient_not_allowed · new_recipient | Not on the allowlist, or new and newRecipients is deny. |
loop_detected (429) | The same request three times in 10 minutes; logged as AGENT_LOOP_DETECTED. |
no_second_approver · four_eyes_required | Four eyes apply and nobody but this person or the owner is listed; a mandate cannot stand in for four eyes. |
mandate_mismatch · mandate_exceeded · mandate_revoked … | Outside the mandate (type, currency, recipient, per request), no room left, or not active. |
suspicious_content (400) | The agent's text carries an instruction to the model or a link built to carry data out (markers: instruction, data_url). Nothing reaches the engine or the phone. |
interactive_required (409) | The checks asked for number matching and the agent asked in the background, where nobody can read the number. Ask again with presence interactive. |
One approval policy says who approves a tool call, as which action type, and what the phone shows (typed details drawn from the tool's arguments). Wrap a tool with withTouchqueApproval (Python: @with_touchque_approval) and it runs only after the person approved on the phone; the wrapped tool keeps its signature, so LangChain, LlamaIndex, the OpenAI Agents SDK and the Vercel AI SDK read the same arguments, and currentApproval() holds the single-use token for your executor. Frameworks with their own approval step get a helper each: approveInterruptionsWithTouchque (OpenAI Agents SDK interruptions), touchqueCanUseTool and touchquePreToolUseHook (Claude Agent SDK), guardMcpClient (an MCP client; the token reaches the server in _meta). Go and PHP have RequireApproval / requireApproval: ask, show the number, wait. An agent gets its own Touchque identity through the SDKs, as above: TouchqueAgent with TQ_AGENT_ID and TQ_AGENT_KEY, and these helpers in its framework. To have an AI coding assistant set this up in your code, use the Touchque MCP; it signs in as you, not as an agent.
Every refusal above answers a caseCode: open it in the dashboard (Logs → Look up a case) to see what went in, which rule said no and what would have passed. The risk engine also checks each request against the agent's own history; each signal is a step-up (the person types the number), never a block on its own, and a background request it steps up is refused with interactive_required. An approval whose number came back sooner than a person can read and type it (something drives the phone) is never usable: status blocked, reason inhuman_approval. A new or renamed agent whose name reads like another agent's of the workspace (look-alike letters, 0/O, 1/l, case, spaces) is refused with name_too_similar, since the phone shows the agent's name first. Webhooks: AGENT_INJECTION_SUSPECTED, AGENT_INHUMAN_APPROVAL, AGENT_LOOKALIKE_NAME, next to the others; changes made in the dashboard send them too.
| Signal | When |
|---|---|
agent_volume_spike | More requests this hour than twice its busiest hour of the past week (at least 10). |
agent_amount_outlier | More than five times its usual amount in this currency, and more than ever before. |
agent_new_network | A network (ASN) the agent never used on an earlier day. |
agent_off_hours | An hour that holds under 1% of its requests. |
agent_new_destination | A recipient this person never approved for this agent. |
For your company's own agents and automations, add them in the dashboard (Organization → AI agents): a sponsor who answers for each one (it stops when they leave), the organization's OpenID Connect apps it may get tokens for, scopes, a review date, and how it proves who it is: its own public key, or the token its platform already gives it (GitHub Actions, GitLab, Google Cloud, Azure, Kubernetes; a trust policy names the issuer, the subject and the audience). No shared secret. It gets 5-minute at+jwt tokens for one app from /sso/token, bound to a DPoP key (cnf.jkt); the app checks them with the SSO's JWKS and the proof on every call, or asks /sso/introspect, which says inactive as soon as the agent is suspended, the kill switch is on or its sponsor leaves. Giving an agent more is approved on the admin's phone. Allowed to act for members, it can trade a member's own token for one that names both (token exchange, RFC 8693: sub the member, act.sub the agent, never more than the member may open), or ask the member on the phone (CIBA, /sso/bc-authorize, poll mode): the phone shows the agent, the app and the typed authorization_details, asks for Face ID and the number the agent shows, and the token carries exactly what was approved.
Add an account connection under Organization → AI agents → Accounts agents may use (Google, Microsoft 365, GitHub, Slack, Salesforce or any OAuth 2.0 service; your phone approves) and name the agents that may use it. Each member connects their own account once on the portal (/connections); Touchque keeps the tokens sealed and refreshes them. An agent then asks /sso/token with token exchange, the member's token, requested_token_type=urn:touchque:params:oauth:token-type:connection-access-token and connection, and gets a Bearer token for that service, never the refresh token; scope may only narrow what the member granted. Nothing comes back once the member disconnects, leaves or is suspended (their tokens are deleted), the agent stops, or the connection is turned off or no longer names it.
Organization → AI agents shows every agent with what it may do, what it did in 30 days and a risk level (acting for members together with members' accounts or shared accounts, many refusals, unused, review overdue), and every AI tool members connected to your MCP servers or tried to, named by the domain it comes from. Approve a tool on your phone, or block it: every connection of it ends at once and nobody can connect it again. With "Only approved AI tools" on, members are refused tools you have not approved. Access reviews can cover AI agents: owners and admins you pick review each one (never its sponsor); keep certifies it for 90 days, revoke revokes it, and the evidence is signed. The AI agent evidence pack (signed JSON + CSV, checked on the public verify page) lists the agents, the AI tools and every token, member approval, account, shared account and tool call in a period. Log streams send agent and AI tool events as category agent.
Attackers drive browsers with software too: Playwright or Selenium, a headless Chrome, an AI browser agent filling in the sign-in page, or a user's own agent that a hidden prompt makes read and leak an email code. Every Touchque sign-in page (SSO, Touchque Login, approvals, the dashboard) sends its own page check; on your pages, @touchque/web startClientSignals() does the same and you pass it as clientSignals. It reports booleans, counts and timings only: navigator.webdriver, automation traces, a headless browser, script-made input, fields filled without typing, machine-regular typing, request headers that do not fit the browser. Signs about how the form was filled count once together, so a password manager filling and sending the form is not enough on its own. The risk engine adds the network (a hosting address, with a sign about the browser itself) and answers automated_client: never a silent sign-in, never an email code or link alone, a passkey or a number-matched phone approval instead. Each workspace, organization and Touchque Login tenant chooses monitor, step_up (default) or block: block refuses on a clear sign (webdriver, automation traces, a headless browser, a tool as the browser) and asks like step_up on weaker ones. Software that types in the agent trap the word its note asks for is refused outside monitor. Every refusal has a case code.
Agents that say who they are are welcome on the agent path, not on the pages for people: a request signed with Web Bot Auth (HTTP Message Signatures, RFC 9421, its keys in the agent's /.well-known/http-message-signatures-directory, fetched over HTTPS only and pinned per host) is recognised as that agent and refused with agent_identity_required and a pointer to agent identities; a signature that does not verify (tampered, replayed, an unknown key) is refused the same way. On the phone, an emulator or simulator, a debugger, developer options or an accessibility service that can tap (other than screen readers and password managers) never approves a critical action, and a workspace can require phones whose hardware is attested (requireAttestedPhone).
Register each of your organization's MCP servers in the dashboard (Organization → MCP servers): its URL, the scopes its tools need with a risk for each, and who may connect. Members then connect Claude, Cursor, VS Code or any MCP client the standard way: the server's protected resource metadata (RFC 9728) points at https://sso.touchque.com/mcp, the client identifies itself with a client ID metadata document (an https URL, no registration), and signs the member in with PKCE (S256) and resource (RFC 8707) required. The member sees a consent screen with the client's address and the scopes; a sensitive scope needs their phone (Face ID and a number). Tokens are 10-minute at+jwt for that one server (aud = its URL), optionally DPoP-bound; refresh tokens rotate, and reusing an old one ends the connection. The admin sees every connection and can end it; narrowing a server or turning it off is instant, loosening it is approved on the phone. On the server, McpAuth in every SDK serves the metadata, checks each token (Touchque's JWKS, issuer, audience, expiry, DPoP proof) and answers 401 or 403 insufficient_scope with the WWW-Authenticate header MCP clients follow.
Turn on the Touchque gateway for a server (its page in the dashboard) to put a policy point between AI tools and it. AI tools then connect to https://sso.touchque.com/sso/mcp/gw/<id> only; a token for the server itself is refused, and the server gets a 2-minute token Touchque mints for each call (act.sub touchque-mcp-gateway), never the AI tool's own. Tools are pinned by the digest of their definition: a new tool is hidden until you approve it, a changed definition hides it again (rug pull), and descriptions that steer the model, name key files, mention another tool or read like another tool's name are flagged. Each tool is allowed, asks the member's phone or is denied; arguments carrying an instruction or a secret ask the phone, personal data or a host outside your allow-list cannot leave through a tool labelled as sending data out, and a session that read untrusted input and private data asks the phone before such a tool (the rule of two). A per-minute budget, a loop breaker and a kill switch stop runaway agents; every call has a case code and reaches your SIEM.
Link one of your organization's OpenID Connect apps (with a client secret) to a service in the dashboard (Organization → AI agents → Cross App Access): your MCP servers, or another service whose authorization server takes ID-JAGs from Touchque (its issuer, the app's client ID there, resources, scopes). Your phone approves it. The app then trades a member's ID token for an Identity Assertion JWT Authorization Grant (ID-JAG, an open IETF draft; the MCP Enterprise-Managed Authorization extension builds on it) at /sso/token, and the ID-JAG for an access token at that service, with no consent screen per member. Each ID-JAG lives 5 minutes (typ oauth-id-jag+jwt), names the member, the service and the app's client ID there, and is checked again every time: the member still in the organization and allowed in the app, the link still on, the AI tool rule. Your own MCP servers take it at /sso/mcp/token (jwt-bearer, once, no refresh token). If you run an authorization server, every SDK checks an ID-JAG for you (issuer, single audience, your client, expiry, used once). Before your agent trusts another agent's A2A card, the SDKs check its signatures (JWS over the RFC 8785 canonical card); keys named by jku are only fetched from the card's own host or hosts you trust.
import { Touchque, TouchqueAgent, generateAgentKey } from '@touchque/node';
const tq = new Touchque(); // TQ_API_KEY / TQ_API_SECRET from the environment
// Once, from your server (an API key with the agents:write scope)
const { privateJwk, publicJwk } = generateAgentKey(); // ES256; 'EdDSA' too
const agent = await tq.agents.create({
name: 'Invoice bot',
allowedTypes: ['PAY_INVOICE'], // the only approvals it may ask for
publicKey: publicJwk, // never the private key
ownerEmail: 'ada@acme.com', // accountable; the agent stops if they leave
platform: 'openai-agents',
});
// agent.id and privateJwk go to the agent's secret store: TQ_AGENT_ID, TQ_AGENT_KEY
// Inside the agent: its own key, DPoP
const me = await new TouchqueAgent().me();
// When something goes wrong
await tq.agents.suspend(agent.id, 'looping');
await tq.agents.setKillSwitch(true, 'incident'); // every agent, at oncefrom touchque import TouchqueAgent, generate_agent_key
keys = generate_agent_key()
agent = client.agents.create('Invoice bot', ['PAY_INVOICE'], public_key=keys['public_jwk'],
owner_email='ada@acme.com', platform='langchain')
me = TouchqueAgent().me() # TQ_AGENT_ID, TQ_AGENT_KEY (the touchque-authenticator[agent] extra)
client.agents.suspend(agent['id'], 'looping')
client.agents.set_kill_switch(True, 'incident')use Touchque\AgentKeys;
use Touchque\TouchqueAgent;
$keys = AgentKeys::generate();
$agent = $tq->agents->create('Invoice bot', ['PAY_INVOICE'], ['publicKey' => $keys['publicJwk'], 'ownerEmail' => 'ada@acme.com']);
$me = (new TouchqueAgent())->me(); // TQ_AGENT_ID, TQ_AGENT_KEY
$tq->agents->suspend($agent['id'], 'looping');
$tq->agents->setKillSwitch(true, 'incident');keys, _ := touchque.GenerateAgentKey("ES256")
agent, err := client.Agents.Create(touchque.CreateAgentParams{
Name: "Invoice bot", AllowedTypes: []string{"PAY_INVOICE"}, PublicKey: keys.PublicJWK, OwnerEmail: "ada@acme.com",
})
a, _ := touchque.NewAgent(touchque.AgentConfig{}) // TQ_AGENT_ID, TQ_AGENT_KEY
me, err := a.Me()
client.Agents.Suspend(agent.ID, "looping")
client.Agents.SetKillSwitch(true, "incident")var keys = AgentKeys.Generate(); // ES256, or AgentKeys.Generate("EdDSA")
var agent = await tq.Agents.CreateAsync(new CreateAgentOptions
{
Name = "Invoice bot", AllowedTypes = ["PAY_INVOICE"], PublicKey = keys.PublicJwk, OwnerEmail = "ada@acme.com",
});
using var bot = new TouchqueAgent(); // TQ_AGENT_ID, TQ_AGENT_KEY
var me = await bot.MeAsync();
await tq.Agents.SuspendAsync(agent.Id, "looping");
await tq.Agents.SetKillSwitchAsync(true, "incident");AgentKeys.Pair keys = AgentKeys.generate(); // ES256, or AgentKeys.generate("EdDSA")
JsonNode agent = tq.agents().create(new Agents.CreateOptions("Invoice bot", List.of("PAY_INVOICE"))
.publicKey(keys.publicJwk()).ownerEmail("ada@acme.com"));
var bot = new TouchqueAgent(); // TQ_AGENT_ID, TQ_AGENT_KEY
JsonNode me = bot.me();
tq.agents().suspend(agent.get("id").asText(), "looping");
tq.agents().setKillSwitch(true, "incident");keys = Touchque::AgentKeys.generate
agent = tq.agents.create("Invoice bot", ["PAY_INVOICE"], public_key: keys["publicJwk"], owner_email: "ada@acme.com")
me = Touchque::Agent.new.me # TQ_AGENT_ID, TQ_AGENT_KEY
tq.agents.suspend(agent["id"], reason: "looping")
tq.agents.set_kill_switch(true, reason: "incident")# Partner: signed request (HMAC), see Authentication
POST /agents
{ "name": "Invoice bot", "allowedTypes": ["PAY_INVOICE"],
"publicKey": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" } }
# Agent: its own key, no API key
POST /agents/token
DPoP: <proof JWT>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<JWT signed by the agent key>
GET /agents/me
Authorization: DPoP tqat_…
DPoP: <proof JWT with ath>// In the agent: typed details, never free text
const ask = await agent.requestApproval({
user: 'jane@example.com', type: 'PAY_INVOICE', reason: 'October rent',
authorizationDetails: [{ type: 'payment_initiation',
instructedAmount: { currency: 'TRY', amount: '500.00' },
creditorName: 'Jane Doe', creditorAccount: { iban: 'TR33 0006 1005 1978 6457 8413 26' } }],
});
reply(`Type ${ask.challengeCode} in the Touchque app to approve.`);
const { approvalToken } = await agent.waitForApproval(ask.approvalId);
// In your executor, right before paying: once, only for this
const approval = await tq.agents.redeemApproval(approvalToken, {
user: 'jane@example.com', type: 'PAY_INVOICE',
authorizationDetails: { type: 'payment_initiation', instructedAmount: { currency: 'TRY', amount: '500.00' } },
});ask = agent.request_approval('jane@example.com', 'PAY_INVOICE', [payment], reason='October rent')
token = agent.wait_for_approval(ask['approvalId'])['approvalToken']
approval = client.agents.redeem_approval(token, user='jane@example.com', authorization_details=payment)$ask = $agent->requestApproval('jane@example.com', 'PAY_INVOICE', [$payment], ['reason' => 'October rent']);
$token = $agent->waitForApproval($ask['approvalId'])['approvalToken'];
$approval = $tq->agents->redeemApproval($token, ['user' => 'jane@example.com', 'authorizationDetails' => $payment]);ask, _ := a.RequestApproval(touchque.AgentApprovalParams{User: "jane@example.com", Type: "PAY_INVOICE",
AuthorizationDetails: []map[string]interface{}{payment}, Reason: "October rent"})
done, _ := a.WaitForApproval(ctx, ask.ApprovalID, 0, 0)
approval, err := client.Agents.RedeemApproval(done.ApprovalToken, touchque.ApprovalExpectation{User: "jane@example.com", AuthorizationDetails: payment})var ask = await bot.RequestApprovalAsync("jane@example.com", "PAY_INVOICE", new[] { payment }, new AgentApprovalOptions { Reason = "October rent" });
var done = await bot.WaitForApprovalAsync(ask.ApprovalId);
var approval = await tq.Agents.RedeemApprovalAsync(done.ApprovalToken!, new ApprovalExpectation { User = "jane@example.com", AuthorizationDetails = payment });var ask = bot.requestApproval("jane@example.com", "PAY_INVOICE", List.of(payment), new TouchqueAgent.ApprovalOptions().reason("October rent"));
var done = bot.waitForApproval(ask.approvalId(), null, null);
JsonNode approval = tq.agents().redeemApproval(done.approvalToken(), new ApprovalToken.Expectation("jane@example.com", null, payment));ask = agent.request_approval("jane@example.com", "PAY_INVOICE", [payment], reason: "October rent")
token = agent.wait_for_approval(ask["approvalId"])["approvalToken"]
approval = tq.agents.redeem_approval(token, user: "jane@example.com", authorization_details: payment)POST /agents/approvals # agent token + DPoP
{ "externalUsername": "jane@example.com", "type": "PAY_INVOICE", "reason": "October rent",
"authorizationDetails": [{ "type": "payment_initiation",
"instructedAmount": { "currency": "TRY", "amount": "500.00" },
"creditorName": "Jane Doe", "creditorAccount": { "iban": "TR330006100519786457841326" } }] }
GET /agents/approvals/{approvalId} # → approved + approvalToken
POST /agents/approvals/redeem # your API key (HMAC)
{ "token": "<approvalToken>" }import { withTouchqueApproval, currentApproval, touchqueCanUseTool, guardMcpClient } from '@touchque/node';
const policy = { agent, user: 'jane@example.com', type: 'PAY_INVOICE',
details: ({ iban, amount }) => [{ type: 'payment_initiation',
instructedAmount: { currency: 'TRY', amount }, creditorAccount: { iban } }],
onChallenge: (s) => reply(`Type ${s.challengeCode} in the Touchque app.`) };
// LangChain tool(), OpenAI Agents tool(), Vercel AI SDK tool(): same signature
const pay = withTouchqueApproval(async ({ iban, amount }) =>
bank.pay(iban, amount, currentApproval().approvalToken), policy);
// Claude Agent SDK
query({ prompt, options: { canUseTool: touchqueCanUseTool({ pay: policy }, { tokenField: 'approvalToken' }) } });
// MCP client: the token goes in _meta["touchque/approvalToken"]
const guarded = guardMcpClient(client, { pay: policy });from touchque import ApprovalPolicy, with_touchque_approval, current_approval
policy = ApprovalPolicy(agent, user='jane@example.com', type='PAY_INVOICE',
details=lambda a, tool: [{'type': 'payment_initiation',
'instructedAmount': {'currency': 'TRY', 'amount': a['amount']},
'creditorAccount': {'iban': a['iban']}}])
@tool # LangChain, LlamaIndex, OpenAI Agents (@function_tool): signature kept
@with_touchque_approval(policy)
def pay(iban: str, amount: str) -> str:
return bank.pay(iban, amount, current_approval()['approvalToken'])
# approve_interruptions_with_touchque(result, {'pay': policy}) OpenAI Agents SDK
# touchque_can_use_tool({'pay': policy}) Claude Agent SDK
# guard_mcp_session(session, {'pay': policy}) MCP ClientSession$done = $agent->requireApproval('jane@example.com', 'PAY_INVOICE', [$payment], [],
fn ($s) => $reply("Type {$s['challengeCode']} in the Touchque app."));
$bank->pay($iban, $amount, $done['approvalToken']);done, err := a.RequireApproval(ctx, touchque.AgentApprovalParams{User: "jane@example.com", Type: "PAY_INVOICE",
AuthorizationDetails: []map[string]interface{}{payment}},
func(s *touchque.AgentApprovalState) { reply("Type " + s.ChallengeCode + " in the Touchque app.") }, 5*time.Minute)
if err != nil { return err } // rejected, blocked, expired
bank.Pay(iban, amount, done.ApprovalToken)var done = await bot.RequireApprovalAsync("jane@example.com", "PAY_INVOICE", new[] { payment },
onChallenge: s => ReplyAsync($"Type {s.ChallengeCode} in the Touchque app.")); // TouchqueApprovalException when refused
await bank.PayAsync(iban, amount, done.ApprovalToken!);var done = bot.requireApproval("jane@example.com", "PAY_INVOICE", List.of(payment), null,
s -> reply("Type " + s.challengeCode() + " in the Touchque app."), null); // TouchqueApprovalException when refused
bank.pay(iban, amount, done.approvalToken());done = agent.require_approval("jane@example.com", "PAY_INVOICE", [payment]) do |s|
reply "Type #{s["challengeCode"]} in the Touchque app."
end # Touchque::ApprovalError when refused
bank.pay(iban, amount, done["approvalToken"])import { TouchqueOrgAgent, githubActionsToken } from '@touchque/node';
// With its own key (TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY)
const agent = new TouchqueOrgAgent();
const res = await agent.fetch('https://payments.acme.com/invoices', { resource: 'tqapp_…', scope: 'invoices:read' });
// In GitHub Actions, no key to store (permissions: id-token: write)
const ci = new TouchqueOrgAgent({ agentId: 'oagt_…', assertion: githubActionsToken('touchque') });
// For a member: with their own token (RFC 8693) or by asking them on the phone (CIBA)
const forAda = await agent.exchangeToken({ subjectToken: adasAccessToken, resource: 'tqapp_…' });
const ask = await agent.askMember({ loginHint: 'ada@acme.com', bindingMessage: 'Pay invoice 42', authorizationDetails: [payment] });
reply(`Type ${ask.bindingCode} in the Touchque app.`);
const approved = await agent.waitForMember(ask); // approved.authorizationDetails
// Token vault: the member's own Google account they connected, no refresh token
const google = await agent.getConnectionToken({ subjectToken: adasAccessToken, connection: 'google' });from touchque import TouchqueOrgAgent, github_actions_token
agent = TouchqueOrgAgent() # TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY
r = agent.request('GET', 'https://payments.acme.com/invoices', resource='tqapp_...', scope='invoices:read')
ci = TouchqueOrgAgent(agent_id='oagt_...', assertion=github_actions_token('touchque'))
google = agent.get_connection_token(adas_access_token, 'google') # token vaultuse Touchque\TouchqueOrgAgent;
$agent = new TouchqueOrgAgent(); // TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY
$headers = $agent->authorization('GET', 'https://payments.acme.com/invoices', ['resource' => 'tqapp_...']);
$ci = new TouchqueOrgAgent(['agentId' => 'oagt_...', 'assertion' => TouchqueOrgAgent::githubActionsToken('touchque')]);
$google = $agent->getConnectionToken($adasAccessToken, 'google'); // token vaultagent, _ := touchque.NewOrgAgent(touchque.OrgAgentConfig{}) // TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY
req, _ := http.NewRequest("GET", "https://payments.acme.com/invoices", nil)
_ = agent.Authorize(req, "tqapp_…", "invoices:read")
ci, _ := touchque.NewOrgAgent(touchque.OrgAgentConfig{AgentID: "oagt_…", Assertion: touchque.GitHubActionsToken("touchque")})
google, _ := agent.GetConnectionToken(adasAccessToken, "google", "") // token vaultusing var agent = new TouchqueOrgAgent(); // TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY
var request = new HttpRequestMessage(HttpMethod.Get, "https://payments.acme.com/invoices");
await agent.AuthorizeAsync(request, "tqapp_…", "invoices:read");
using var ci = new TouchqueOrgAgent(new TouchqueOrgAgentOptions { AgentId = "oagt_…", Assertion = TouchqueOrgAgent.GitHubActionsToken("touchque") });
var google = await agent.GetConnectionTokenAsync(adasAccessToken, "google"); // token vaultvar agent = new TouchqueOrgAgent(); // TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY
var request = agent.authorize(HttpRequest.newBuilder(URI.create("https://payments.acme.com/invoices")),
"GET", "https://payments.acme.com/invoices", "tqapp_…", "invoices:read");
var ci = new TouchqueOrgAgent(new TouchqueOrgAgent.Options().agentId("oagt_…").assertion(TouchqueOrgAgent.githubActionsToken("touchque")));
var google = agent.getConnectionToken(adasAccessToken, "google", null); // token vaultagent = Touchque::OrgAgent.new # TQ_ORG_AGENT_ID, TQ_ORG_AGENT_KEY
headers = agent.authorization("GET", "https://payments.acme.com/invoices", resource: "tqapp_…", scope: "invoices:read")
ci = Touchque::OrgAgent.new(agent_id: "oagt_…", assertion: Touchque::OrgAgent.github_actions_token("touchque"))
google = agent.get_connection_token(adas_access_token, "google") # token vaultPOST https://sso.touchque.com/sso/token
DPoP: <proof>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=oagt_…
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<a JWT signed with its key, or its platform's token>
&resource=tqapp_…&scope=invoices:read
# → { "access_token": "<at+jwt>", "token_type": "DPoP", "expires_in": 300 }// In the browser (your sign-in page)
import { startClientSignals, addAgentTrap } from '@touchque/web';
const probe = startClientSignals(); // on page load
addAgentTrap(form); // a field off the screen: people never see it; software that types the word its note asks for is refused
form.onsubmit = () => { form.clientSignals.value = probe.encode(); };
// On your server
const risk = await tq.risk.check({ externalUsername, clientIp, browserProof, clientSignals: req.body.clientSignals });
await tq.login.request({ externalUsername, type: 'LOGIN', clientIp, clientSignals: req.body.clientSignals });
// software at the browser: never silent, number matching; or 403 automated_clientrisk = tq.risk.check(user, client_ip=ip, browser_proof=proof, client_signals=form['clientSignals'])
tq.login.request(user, type='LOGIN', client_ip=ip, client_signals=form['clientSignals'])$risk = $tq->risk->check(['externalUsername' => $user, 'clientIp' => $ip, 'clientSignals' => $_POST['clientSignals']]);
$tq->login->request($user, 'LOGIN', null, $ip, null, false, false, null, null, null, null, null, $_POST['clientSignals']);risk, _ := tq.Risk.Check(ctx, touchque.RiskCheckOptions{ExternalUsername: user, ClientIP: ip, ClientSignals: r.FormValue("clientSignals")})
_, err := tq.Login.RequestWithOptions(touchque.LoginRequestOptions{ExternalUsername: user, ClientIP: ip, ClientSignals: r.FormValue("clientSignals")})var risk = await tq.Risk.CheckAsync(new RiskCheckOptions { ExternalUsername = user, ClientIp = ip, ClientSignals = form["clientSignals"] });
await tq.Login.RequestAsync(new LoginRequestOptions { ExternalUsername = user, ClientIp = ip, ClientSignals = form["clientSignals"] });var risk = tq.risk().check(new Risk.CheckOptions(user).clientIp(ip).clientSignals(request.getParameter("clientSignals")));
tq.login().request(new LoginRequestOptions(user).clientIp(ip).clientSignals(request.getParameter("clientSignals")));risk = tq.risk.check(user, client_ip: ip, browser_proof: proof, client_signals: params[:clientSignals])
tq.login.request(user, type: "LOGIN", client_ip: ip, client_signals: params[:clientSignals])# Web Bot Auth: a signed agent's request
Signature-Agent: "https://agent.example"
Signature-Input: sig1=("@authority" "signature-agent");created=…;expires=…;keyid="<JWK thumbprint>";alg="ed25519";nonce="…";tag="web-bot-auth"
Signature: sig1=:…:
# → 403 { "error": "agent_identity_required", "agent": "agent.example", "caseCode": "…" }import { McpAuth } from '@touchque/node';
const auth = new McpAuth({
issuer: 'https://sso.touchque.com/mcp', // Organization → MCP servers
resource: 'https://mcp.acme.com/mcp', // exactly as registered
tools: { search_tickets: 'tickets:read', delete_ticket: 'tickets:delete' },
});
app.use(auth.express()); // serves the metadata; 401 + WWW-Authenticate without a token
// before running a tool
auth.requireTool(req.touchqueMcp, 'delete_ticket'); // 403 insufficient_scope: the client asks the member for morefrom touchque import McpAuth, McpAuthError
auth = McpAuth(issuer='https://sso.touchque.com/mcp', resource='https://mcp.acme.com/mcp',
tools={'search_tickets': 'tickets:read', 'delete_ticket': 'tickets:delete'})
claims = auth.verify(request.method, str(request.url), dict(request.headers))
auth.require_tool(claims, 'delete_ticket')
# FastMCP TokenVerifier: auth.verify_token(token)use Touchque\McpAuth;
$auth = new McpAuth(['issuer' => 'https://sso.touchque.com/mcp', 'resource' => 'https://mcp.acme.com/mcp',
'tools' => ['search_tickets' => 'tickets:read', 'delete_ticket' => 'tickets:delete']]);
$claims = $auth->verify($_SERVER['REQUEST_METHOD'], $url, getallheaders());
$auth->requireTool($claims, 'delete_ticket'); // TouchqueMcpAuthException: $e->status, $e->wwwAuthenticateauth, _ := touchque.NewMcpAuth(touchque.McpAuthConfig{
Issuer: "https://sso.touchque.com/mcp", Resource: "https://mcp.acme.com/mcp",
Tools: map[string]string{"search_tickets": "tickets:read", "delete_ticket": "tickets:delete"},
})
http.Handle("/", auth.Middleware(mcpHandler))
claims := touchque.McpClaimsFrom(r.Context())
if err := auth.RequireTool(claims, "delete_ticket"); err != nil {
touchque.WriteMcpAuthError(w, err)
}var auth = new McpAuth(new McpAuthOptions
{
Issuer = "https://sso.touchque.com/mcp", Resource = "https://mcp.acme.com/mcp",
Tools = new Dictionary<string, string> { ["search_tickets"] = "tickets:read", ["delete_ticket"] = "tickets:delete" },
});
app.MapTouchqueMcpMetadata(auth); // /.well-known/oauth-protected-resource/mcp
app.MapPost("/mcp", HandleMcp).RequireTouchqueMcp(auth); // 401 + WWW-Authenticate without a valid token
auth.RequireTool(http.GetTouchqueMcpClaims()!, "delete_ticket"); // TouchqueMcpAuthException: Status, WwwAuthenticate# application.properties (Spring Boot starter)
touchque.mcp.issuer=https://sso.touchque.com/mcp
touchque.mcp.resource=https://mcp.acme.com/mcp
touchque.mcp.tools.search_tickets=tickets:read
touchque.mcp.tools.delete_ticket=tickets:delete
# → /.well-known/oauth-protected-resource/mcp, and 401 + WWW-Authenticate on /mcp without a valid token
// In the handler:
mcpAuth.requireTool(TouchqueSpring.mcpClaims(request), "delete_ticket"); // TouchqueMcpAuthException: status(), wwwAuthenticate()auth = Touchque::McpAuth.new(issuer: "https://sso.touchque.com/mcp", resource: "https://mcp.acme.com/mcp",
tools: { "search_tickets" => "tickets:read", "delete_ticket" => "tickets:delete" })
claims = auth.verify(request.request_method, request.url, request.headers)
auth.require_tool(claims, "delete_ticket") # Touchque::McpAuthError: status, www_authenticateGET https://mcp.acme.com/.well-known/oauth-protected-resource/mcp
# → { "resource": "https://mcp.acme.com/mcp", "authorization_servers": ["https://sso.touchque.com/mcp"] }
GET https://sso.touchque.com/sso/mcp/authorize?response_type=code
&client_id=https://tool.example/oauth/client.json # client ID metadata document
&redirect_uri=…&code_challenge=…&code_challenge_method=S256
&resource=https://mcp.acme.com/mcp&scope=tickets:read&state=…
POST https://sso.touchque.com/sso/mcp/token
grant_type=authorization_code&code=…&code_verifier=…&resource=https://mcp.acme.com/mcp
# → { "access_token": "<at+jwt>", "token_type": "Bearer", "expires_in": 600, "refresh_token": "…" }import { requestIdJag, redeemIdJag, IdJagVerifier, verifyAgentCard } from '@touchque/node';
// Your app (an OIDC app with a secret): the member's ID token → ID-JAG → access token
const { assertion } = await requestIdJag({ issuer: 'https://sso.touchque.com', clientId, clientSecret, idToken, audience: 'https://auth.files.example' });
const tokens = await redeemIdJag({ tokenEndpoint: 'https://auth.files.example/token', clientId: 'acme', clientSecret: filesSecret, assertion });
// Your own authorization server (jwt-bearer): used once
const claims = await new IdJagVerifier({ issuer: 'https://sso.touchque.com', audience: 'https://auth.files.example' })
.verify(form.assertion, { clientId: authenticatedClientId });
// Another agent's A2A card
await verifyAgentCard(card, { cardUrl: 'https://agent.example/.well-known/agent-card.json' });jag = request_id_jag(issuer='https://sso.touchque.com', client_id=client_id, client_secret=secret,
id_token=id_token, audience='https://auth.files.example')
claims = IdJagVerifier(issuer='https://sso.touchque.com', audience='https://auth.files.example') \
.verify(form['assertion'], client_id=authenticated_client_id)
verify_agent_card(card, card_url='https://agent.example/.well-known/agent-card.json')$jag = CrossAppAccess::requestIdJag(['issuer' => 'https://sso.touchque.com', 'clientId' => $id, 'clientSecret' => $secret,
'idToken' => $idToken, 'audience' => 'https://auth.files.example']);
$claims = (new IdJagVerifier(['issuer' => 'https://sso.touchque.com', 'audience' => 'https://auth.files.example']))
->verify($_POST['assertion'], $authenticatedClientId);
AgentCard::verify($cardJson, ['cardUrl' => 'https://agent.example/.well-known/agent-card.json']);jag, err := touchque.RequestIDJAG(ctx, touchque.RequestIDJAGParams{Issuer: "https://sso.touchque.com",
ClientID: id, ClientSecret: secret, IDToken: idToken, Audience: "https://auth.files.example"})
v, _ := touchque.NewIDJAGVerifier(touchque.IDJAGVerifierConfig{Issuer: "https://sso.touchque.com", Audience: "https://auth.files.example"})
claims, err := v.Verify(ctx, r.PostFormValue("assertion"), authenticatedClientID)
res, err := touchque.VerifyAgentCard(ctx, cardJSON, touchque.VerifyAgentCardOptions{CardURL: "https://agent.example/.well-known/agent-card.json"})var jag = await CrossAppAccess.RequestIdJagAsync(new() { Issuer = "https://sso.touchque.com", ClientId = id,
ClientSecret = secret, IdToken = idToken, Audience = "https://auth.files.example" });
var claims = await new IdJagVerifier(new() { Issuer = "https://sso.touchque.com", Audience = "https://auth.files.example" })
.VerifyAsync(form["assertion"]!, authenticatedClientId);
await AgentCard.VerifyAsync(cardJson, new() { CardUrl = "https://agent.example/.well-known/agent-card.json" });var jag = CrossAppAccess.requestIdJag(new CrossAppAccess.RequestOptions(id, secret, idToken, "https://auth.files.example")
.issuer("https://sso.touchque.com"));
var claims = new IdJagVerifier(new IdJagVerifier.Options("https://sso.touchque.com", "https://auth.files.example"))
.verify(form.get("assertion"), authenticatedClientId);
AgentCard.verify(cardJson, new AgentCard.VerifyOptions().cardUrl("https://agent.example/.well-known/agent-card.json"));jag = Touchque::CrossAppAccess.request_id_jag(issuer: "https://sso.touchque.com", client_id: id, client_secret: secret,
id_token: id_token, audience: "https://auth.files.example")
claims = Touchque::IdJagVerifier.new(issuer: "https://sso.touchque.com", audience: "https://auth.files.example")
.verify(params[:assertion], client_id: authenticated_client_id)
Touchque::AgentCard.verify(card_json, card_url: "https://agent.example/.well-known/agent-card.json")POST https://sso.touchque.com/sso/token # the app's client_id + client_secret
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&subject_token=<ID token>&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&audience=https://auth.files.example&scope=files:read
# → { "access_token": "<ID-JAG>", "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", "token_type": "N_A", "expires_in": 300 }
POST https://sso.touchque.com/sso/mcp/token # your own MCP servers
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<ID-JAG>&resource=https://mcp.acme.com/mcp// Ask once: at most 5 invoices, 10,000 TRY in total, this IBAN, 24 hours
const m = await agent.requestMandate({
user: 'jane@example.com', type: 'PAY_INVOICE', maxCount: 5,
maxTotal: { currency: 'TRY', amount: '10000.00' }, perRequest: '2500.00',
recipients: ['TR33 0006 1005 1978 6457 8413 26'], validFor: 86400,
});
reply(`Type ${m.challengeCode} in the Touchque app.`);
// Once active: each payment is approved without a push, counted atomically
const ask = await agent.requestApproval({ user: 'jane@example.com', type: 'PAY_INVOICE',
authorizationDetails: [payment], mandateId: m.mandateId });
// ask.status === "approved", token in the answer; redeemApproval as always
await tq.agents.setPolicy(agentId, { fourEyes: { approvers: ['cfo@acme.com'], above: { TRY: '5000' } } });m = agent.request_mandate('jane@example.com', 'PAY_INVOICE', 5, {'currency': 'TRY', 'amount': '10000.00'},
per_request='2500.00', recipients=[iban], valid_for=86400)
ask = agent.request_approval('jane@example.com', 'PAY_INVOICE', [payment], mandate_id=m['mandateId'])
client.agents.set_policy(agent_id, {'fourEyes': {'approvers': ['cfo@acme.com'], 'above': {'TRY': '5000'}}})$m = $agent->requestMandate('jane@example.com', 'PAY_INVOICE', 5, ['currency' => 'TRY', 'amount' => '10000.00'],
['perRequest' => '2500.00', 'recipients' => [$iban], 'validFor' => 86400]);
$ask = $agent->requestApproval('jane@example.com', 'PAY_INVOICE', [$payment], ['mandateId' => $m['mandateId']]);
$tq->agents->setPolicy($agentId, ['fourEyes' => ['approvers' => ['cfo@acme.com'], 'above' => ['TRY' => '5000']]]);m, _ := a.RequestMandate(touchque.MandateParams{User: "jane@example.com", Type: "PAY_INVOICE", MaxCount: 5,
MaxTotal: map[string]string{"currency": "TRY", "amount": "10000.00"}, PerRequest: "2500.00", Recipients: []string{iban}})
ask, _ := a.RequestApproval(touchque.AgentApprovalParams{User: "jane@example.com", Type: "PAY_INVOICE",
AuthorizationDetails: []map[string]interface{}{payment}, MandateID: m.MandateID})
client.Agents.SetPolicy(agentID, touchque.AgentPolicy{FourEyes: &touchque.FourEyes{Approvers: []string{"cfo@acme.com"},
Above: map[string]string{"TRY": "5000"}}})var m = await bot.RequestMandateAsync("jane@example.com", "PAY_INVOICE", 5, new MandateAmount("TRY", "10000.00"),
new MandateOptions { PerRequest = new MandateAmount("TRY", "2500.00"), Recipients = [iban], ValidFor = 86400 });
var ask = await bot.RequestApprovalAsync("jane@example.com", "PAY_INVOICE", new[] { payment }, new AgentApprovalOptions { MandateId = m.MandateId });
await tq.Agents.SetPolicyAsync(agentId, new AgentPolicy
{
FourEyes = new FourEyes { Approvers = ["cfo@acme.com"], Above = new Dictionary<string, string> { ["TRY"] = "5000" } },
});JsonNode m = bot.requestMandate("jane@example.com", "PAY_INVOICE", 5, new TouchqueAgent.MandateAmount("TRY", "10000.00"),
new TouchqueAgent.MandateOptions().perRequest(new TouchqueAgent.MandateAmount("TRY", "2500.00")).recipients(List.of(iban)).validFor(86400));
var ask = bot.requestApproval("jane@example.com", "PAY_INVOICE", List.of(payment),
new TouchqueAgent.ApprovalOptions().mandateId(m.get("mandateId").asText()));
tq.agents().setPolicy(agentId, Map.of("fourEyes", Map.of("approvers", List.of("cfo@acme.com"), "above", Map.of("TRY", "5000"))));m = agent.request_mandate("jane@example.com", "PAY_INVOICE", 5, { currency: "TRY", amount: "10000.00" },
per_request: "2500.00", recipients: [iban], valid_for: 86400)
ask = agent.request_approval("jane@example.com", "PAY_INVOICE", [payment], mandate_id: m["mandateId"])
tq.agents.set_policy(agent_id, { fourEyes: { approvers: ["cfo@acme.com"], above: { TRY: "5000" } } })POST /agents/mandates # agent token + DPoP
{ "externalUsername": "jane@example.com", "type": "PAY_INVOICE", "maxCount": 5,
"maxTotal": { "currency": "TRY", "amount": "10000.00" }, "perRequest": "2500.00",
"recipients": ["TR330006100519786457841326"], "validFor": 86400 }
POST /agents/approvals # with "mandateId": "…": no push
POST /agents/mandates/{id}/revoke # the agent gives it up
POST /agents/{agentId}/mandates/{id}/revoke # you (HMAC)
PUT /agents/{agentId}/policy # { "fourEyes": { "approvers": […], "above": { "TRY": "5000" } } }{
"access_token": "tqat_…",
"token_type": "DPoP",
"expires_in": 600,
"scope": "agent:read approvals:request approvals:read"
}