
Thin TypeScript + zero-dep Python client and recipes to gate high-risk actions behind a payload-bound passkey approval.
Thin, zero-dependency TypeScript client for Cosignet — human-in-the-loop approval for high-risk AI-agent actions, with payload-bound passkey signatures.
Put a human in the loop before a dangerous action runs: pause it, get an explicit
passkey approval from a person (Face ID / Touch ID / Windows Hello / security
key), and continue only on a signed decision bound to the exact payload —
change the action afterward and the signature no longer matches. Cosignet is an
approval and evidence layer, not an executor or policy engine. Runs anywhere with
global fetch + Web Crypto: Node 18+, Cloudflare Workers, Deno, and browsers.
Status: early access. Published on npm as
@cosignet/sdk.
npm install @cosignet/sdk
import { Cosignet } from '@cosignet/sdk';
const cosignet = new Cosignet({ apiKey: process.env.COSIGNET_API_KEY! });
const decision = await cosignet.requestApproval(
{
username: 'alex',
action: 'Wire transfer to vendor',
payload: { to: 'acct_8821', amount_usd: 4200, memo: 'INV-2025-118' },
notify: 'telegram_or_email',
},
{ onCreated: (c) => console.log('Approve here:', c.url) },
);
if (decision.status === 'approved') {
// proceed — decision.rawAssertion is the signed proof
} else {
// 'rejected' | 'expired' | 'pending' (timed out)
}
requestApproval long-polls over your own outbound connection (~25s hops), so it
works from CLI tools and locked-down VPCs behind NAT/firewalls — no inbound
webhook, open port, or public IP required.
If your system cannot obtain a signed approval, the gated action must not run. Unavailability must never degrade into auto-approval. Treat every timeout, network error, and 5xx as "not approved".
Implementation notes:
See When Cosignet is unavailable for the full availability and break-glass guidance.
const created = await cosignet.createConfirmation({ username, action, payload });
const status = await cosignet.getConfirmation(created.id, { wait: 25 }); // long-poll
Pass idempotencyKey (sent as the Idempotency-Key header) so a retried create —
e.g. after a network blip interrupts a long-poll — with the same key and same
action/payload returns the original confirmation (idempotent: true) instead
of minting a duplicate or re-notifying the approver. Reusing a key with different
parameters is rejected with a 422.
await cosignet.createConfirmation({
username: 'alex',
action: 'Wire transfer to vendor',
payload: { to: 'acct_8821', amount_usd: 4200 },
idempotencyKey: 'wire-INV-2025-118', // stable per logical operation
});
Pass returnUrl to show a "Return to <host>" button on the approval page
once the request resolves (approved / rejected / expired) — handy so a human can
get back to your app to re-trigger an action that timed out. It must be an https
URL and is only ever rendered as a click-through link (never fetched server-side).
await cosignet.requestApproval({
username: 'alex',
action: 'Wire transfer to vendor',
payload: { to: 'acct_8821', amount_usd: 4200 },
returnUrl: 'https://app.example.com/approvals',
});
notify ('none' | 'telegram' | 'email' | 'telegram_or_email') controls the
personal-reach ping to the specific signer:
telegram — a DM to the approver's linked Telegram (telegram_or_email prefers
this when the approver has linked a chat).email — an email to the approver's own address. Priority: the direct email
you set on the approver in the dashboard (delivery-only), then their verified member
email, then the account contact address as a last resort.telegram_or_email — Telegram if linked, otherwise the email above.none — no personal ping.Separately, team reach is a Slack broadcast to a shared channel, configured
per-account in the dashboard. It is additive and always fires when configured,
independent of notify (so notify:'none' still posts to Slack). Approvers get
their Telegram and email set from the dashboard's Approvers section.
Note on
public: truereveal: the email committed into the transparency log is the account's designated verified email (chosen in dashboard settings, as the accountable party — not the individual signer). The direct notify email is delivery-only and never used for the reveal hash.
Every channel is link-only: the notification carries the approval URL and never
the action or payload. Approval always requires the approver's payload-bound
passkey on the confirmation page — there is no approve-in-channel.
verifyWebhookSignature recomputes the hex HMAC-SHA256 of the raw request
body and constant-time compares it to the Cosignet-Signature header. Pass the
exact bytes you received — re-serializing JSON changes the signature.
import { verifyWebhookSignature } from '@cosignet/sdk';
const ok = await verifyWebhookSignature({
body: rawBody, // raw string, not re-parsed JSON
signature: req.headers['cosignet-signature'],
secret: process.env.COSIGNET_WEBHOOK_SECRET!,
timestamp: req.headers['cosignet-timestamp'], // optional replay protection
toleranceSeconds: 300, // optional
});
if (!ok) return res.status(401).end();
See docs/verification.md for the caller-side checklist: continue only on approved, compare the decision to the operation you are about to run, and treat rejected/expired/timeouts as hard stops.
new Cosignet({ apiKey, baseUrl?, fetch? })createConfirmation(input) → CreatedConfirmationgetConfirmation(id, { wait? }) → ConfirmationrequestApproval(input, { timeoutMs?, onCreated? }) → ConfirmationverifyWebhookSignature({ body, signature, secret, timestamp?, toleranceSeconds? }) → Promise<boolean>By default the transparency log withholds the payload and exposes no approver
identity. Set public: true on createConfirmation/requestApproval to opt a
single approval into public reveal: its raw action + payload become
readable in the verification bundle, and a PBKDF2 hash of the account's
designated verified email (chosen in dashboard settings — the accountable
party, not the individual signer) is committed into the Merkle leaf so anyone can
check a candidate address against it.
await cosignet.requestApproval({
username: 'alice',
action: 'Publish Q3 board resolution',
payload: { docId: 'res-2026-Q3' },
public: true, // permanent, irreversible — see caveats below
});
examples/ — copy-pasteable cURL, Node, Python, GitLab CI, MCP/tool-wrapper, and Worker examples.security/ — payload binding, WebAuthn proof model, threat model, transparency log, and responsible disclosure.Gate high-risk actions behind a passkey approval:
recipes/ai-agent-gate — pause an AI coding agent (Claude Code) before it runs a dangerous shell command until a human co-signs.recipes/cli-approval — wrap any command (e.g. terraform destroy).recipes/ci-cd — block a pipeline (GitHub Actions / GitLab CI) on approval.recipes/backend-service — pause an irreversible op in a service.[email protected]; do not file public issues for vulnerabilities.[email protected].MIT © Cosignet
| Signal | Meaning | Your action |
|---|
status: approved (signature verifies) | Human approved this exact payload | Proceed |
status: rejected | Human rejected | Do not run; surface to requester |
status: expired | Nobody decided in time | Do not run; re-request if still needed |
| Timeout, network error, or 5xx | Unknown state | Do not run; retry with backoff; alert after N failures |