{"id":46527491,"url":"https://github.com/leopechnicki/im_robot","last_synced_at":"2026-05-22T16:01:40.119Z","repository":{"id":340887171,"uuid":"1167717750","full_name":"leopechnicki/im_robot","owner":"leopechnicki","description":"Reverse-CAPTCHA for AI agents — verify bots, not humans. Multi-framework (React, Vue, Svelte, Web Components). Zero dependencies. TypeScript.","archived":false,"fork":false,"pushed_at":"2026-04-02T19:32:32.000Z","size":14619,"stargazers_count":7,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-03T06:40:22.700Z","etag":null,"topics":["ai-agent","authentication","bot-verification","captcha","react","reverse-captcha","security","svelte","typescript","vue","web-components","zero-dependencies"],"latest_commit_sha":null,"homepage":"https://imrobot.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/leopechnicki.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-26T15:53:58.000Z","updated_at":"2026-04-02T19:32:35.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/leopechnicki/im_robot","commit_stats":null,"previous_names":["leopechnicki/im_robot"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/leopechnicki/im_robot","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leopechnicki%2Fim_robot","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leopechnicki%2Fim_robot/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leopechnicki%2Fim_robot/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leopechnicki%2Fim_robot/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/leopechnicki","download_url":"https://codeload.github.com/leopechnicki/im_robot/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leopechnicki%2Fim_robot/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31755536,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-13T13:27:56.013Z","status":"ssl_error","status_checked_at":"2026-04-13T13:21:23.512Z","response_time":93,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai-agent","authentication","bot-verification","captcha","react","reverse-captcha","security","svelte","typescript","vue","web-components","zero-dependencies"],"created_at":"2026-03-06T21:03:38.866Z","updated_at":"2026-05-22T16:01:40.111Z","avatar_url":"https://github.com/leopechnicki.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n# 🤖 imrobot\n\n**Reverse-CAPTCHA for AI agents — verify bots, not humans.**\n\n[![npm version](https://img.shields.io/npm/v/imrobot.svg?style=flat-square\u0026color=3b82f6)](https://www.npmjs.com/package/imrobot)\n[![npm downloads](https://img.shields.io/npm/dw/imrobot.svg?style=flat-square\u0026color=10b981)](https://www.npmjs.com/package/imrobot)\n[![license](https://img.shields.io/npm/l/imrobot.svg?style=flat-square\u0026color=6366f1)](https://github.com/leopechnicki/im_robot/blob/main/LICENSE)\n[![TypeScript](https://img.shields.io/badge/TypeScript-100%25-3178c6?style=flat-square\u0026logo=typescript\u0026logoColor=white)](https://github.com/leopechnicki/im_robot)\n[![zero dependencies](https://img.shields.io/badge/dependencies-0-22c55e?style=flat-square)](https://www.npmjs.com/package/imrobot)\n\n[Live Demo](https://imrobot.vercel.app) · [npm](https://www.npmjs.com/package/imrobot) · [Dev.to Article](https://dev.to/leo_pechnicki/why-i-built-a-captcha-that-only-bots-can-solve-30np)\n\n\u003c/div\u003e\n\n---\n\n## Why?\n\nTraditional CAPTCHAs prove you're human. But what about the opposite?\n\nAs AI agents become first-class web citizens — browsing, booking, purchasing, automating — some systems need to gate access to **agent-facing endpoints** without forcing every caller to enroll a key. Think agent-only APIs, AI-only platforms, or multi-agent authentication.\n\n**imrobot** flips the CAPTCHA model: it generates deterministic challenge pipelines that are trivial for any LLM or programmatic agent to solve (\u003c 1 second), but impractical for humans to work through manually.\n\n### What this protocol does and doesn't prove\n\n- ✅ Proves the caller can **execute a deterministic compute pipeline** (string transforms, bytewise ops, hashing) end-to-end without human interaction.\n- ✅ Proves the verification request was **issued by a server holding the same HMAC secret** (no cross-site replay).\n- ✅ Issues a **standards-compliant JWT** (RFC 7519, HS256) that downstream services can verify with any JWT library.\n- ⚠️ Does **not** cryptographically identify a specific bot — anyone who can run JS in a browser console can call `solveChallenge()` and pass.\n- ⚠️ Does **not** replace cryptographic agent identity. For verified-bot identity, layer this with [Cloudflare's Web Bot Auth](https://developers.cloudflare.com/bots/concepts/bot/verified-bots/web-bot-auth/) (HTTP Message Signatures, RFC 9421), mTLS, or per-agent OAuth credentials.\n- ⚠️ The `sha256_hash` operation is **misnamed for historical reasons** — it cascades FNV-1a 8 times into 64 hex characters; it is **not** RFC 6234 SHA-256. Use `fnv1a_cascade` in new code (same wire output).\n\nThe library's positioning is \"zero-enrollment behavioural gate that survives serialization\" — not \"cryptographic proof of bot identity.\" Use the right tool for the job.\n\n### Protocol versioning\n\nThe `version` field on the discovery document (`/.well-known/imrobot.json`) is the protocol version, not the package version. The current protocol version is `1.0`. Backwards-incompatible wire-format changes will bump the major.\n\n## How it works\n\nimrobot generates a pipeline of deterministic operations (string transforms, byte operations, hashing, and more) applied to a random seed. AI agents parse the structured challenge data, execute the pipeline, and submit the result. Humans would need to manually compute multi-step transformations — practically impossible without tools.\n\n```\nseed: \"a7f3b2c1d4e5f609\"\n  1. reverse()\n  2. caesar(7)\n  3. xor_encode(42)\n  4. fnv1a_hash()\n  5. to_upper()\n```\n\nThe challenge data is embedded in the DOM via `data-imrobot-challenge` attribute as structured JSON, making it trivially parseable by any agent.\n\n## Install\n\n```bash\nnpm install imrobot\n```\n\n## Quick start\n\n### React\n\n```tsx\nimport { ImRobot } from 'imrobot/react'\n\nfunction App() {\n  return (\n    \u003cImRobot\n      difficulty=\"medium\"\n      theme=\"light\"\n      onVerified={(token) =\u003e {\n        console.log('Robot verified!', token)\n      }}\n    /\u003e\n  )\n}\n```\n\n### Vue\n\n```vue\n\u003cscript setup\u003e\nimport { ImRobot } from 'imrobot/vue'\n\nfunction handleVerified(token) {\n  console.log('Robot verified!', token)\n}\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cImRobot difficulty=\"medium\" theme=\"light\" @verified=\"handleVerified\" /\u003e\n\u003c/template\u003e\n```\n\n### Svelte\n\n```svelte\n\u003cscript\u003e\n  import ImRobot from 'imrobot/svelte'\n\u003c/script\u003e\n\n\u003cImRobot\n  difficulty=\"medium\"\n  theme=\"light\"\n  onVerified={(token) =\u003e console.log('Robot verified!', token)}\n/\u003e\n```\n\n### Web Component (Angular, vanilla JS, anything)\n\n```html\n\u003cscript type=\"module\"\u003e\n  import { register } from 'imrobot/web-component'\n  register() // registers \u003cimrobot-widget\u003e\n\u003c/script\u003e\n\n\u003cimrobot-widget difficulty=\"medium\" theme=\"light\"\u003e\u003c/imrobot-widget\u003e\n\n\u003cscript\u003e\n  document.querySelector('imrobot-widget').addEventListener('imrobot-verified', (e) =\u003e {\n    console.log('Robot verified!', e.detail)\n  })\n\u003c/script\u003e\n```\n\n### Core API (headless)\n\n```ts\nimport { generateChallenge, solveChallenge, verifyAnswer } from 'imrobot/core'\n\nconst challenge = generateChallenge({ difficulty: 'medium' })\nconst answer = solveChallenge(challenge)\nconst isValid = verifyAnswer(challenge, answer) // true\n```\n\n### Server SDK (HMAC-signed verification)\n\nFor production use, the server SDK provides tamper-proof, stateless challenge verification using HMAC-SHA256. No database required — the cryptographic signature ensures integrity.\n\n```ts\nimport { createVerifier } from 'imrobot/server'\n\nconst verifier = createVerifier({\n  secret: process.env.IMROBOT_SECRET!, // min 16 chars\n  difficulty: 'medium',\n})\n\n// API route: generate a signed challenge\napp.get('/api/challenge', async (req, res) =\u003e {\n  const challenge = await verifier.generate()\n  res.json(challenge) // includes HMAC signature\n})\n\n// API route: verify agent's answer (stateless)\napp.post('/api/verify', async (req, res) =\u003e {\n  const { challenge, answer } = req.body\n  const result = await verifier.verify(challenge, answer)\n  // result: { valid: true, elapsed: 42, suspicious: false }\n  // or:     { valid: false, reason: 'wrong_answer' | 'expired' | 'invalid_hmac' | 'tampered' | 'replay' }\n  res.json(result)\n})\n```\n\nThe server verifier checks in order: HMAC signature validity (challenge and pipeline not tampered), expiration (challenge not expired), answer correctness (pipeline re-executed), and replay detection (duplicate challenge IDs are rejected when a replay guard is configured). A different secret on a different server will reject the challenge — preventing cross-site replay attacks.\n\n### Middleware \u0026 Proof-of-Agent tokens\n\nProtect your API endpoints with framework-agnostic middleware. Verified agents receive a **standards-compliant JWT** (RFC 7519, `alg: HS256`) that they pass via `X-Agent-Proof` header on subsequent requests. The token decodes cleanly with any JWT library (`jose`, `jsonwebtoken`, `jwt-decode`, ...).\n\nToken shape:\n\n```json\n// Header\n{ \"alg\": \"HS256\", \"typ\": \"JWT\", \"kid\": \"k-2026-04\" }\n\n// Payload (claims in seconds-since-epoch per RFC 7519 §4.1.4)\n{\n  \"iss\": \"imrobot\",\n  \"sub\": \"agent_123\",\n  \"iat\": 1711540860,\n  \"nbf\": 1711540860,\n  \"exp\": 1711544460,\n  \"jti\": \"imr_abcd1234\",\n  \"imr\": {\n    \"challenge_id\": \"ch_abc\",\n    \"difficulty\": \"hard\",\n    \"solve_time_ms\": 42,\n    \"suspicious\": false,\n    \"version\": 2,\n    \"turnstile_verified\": true\n  }\n}\n```\n\n```ts\nimport { requireAgent, createAgentRouter } from 'imrobot/server'\n\n// Mount challenge/verify endpoints with rate limiting\nconst router = createAgentRouter({\n  secret: process.env.IMROBOT_SECRET!,\n  rateLimit: { windowMs: 60_000, maxRequests: 30 },\n})\napp.get('/imrobot/challenge', router.challenge)\napp.post('/imrobot/verify', router.verify)\n\n// Protect routes — only verified agents can access\nconst agentOnly = requireAgent({\n  secret: process.env.IMROBOT_SECRET!,\n  rateLimit: { windowMs: 60_000, maxRequests: 30 },\n})\napp.get('/api/data', agentOnly, (req, res) =\u003e {\n  res.json({ agent: req.agentProof })\n})\n```\n\n#### `trustProxy` option\n\nBoth `requireAgent` and `createAgentRouter` accept a `trustProxy` option that controls how client IPs are resolved for rate limiting. When running behind a reverse proxy (nginx, Cloudflare, etc.), set `trustProxy: true` to read the real client IP from `X-Forwarded-For` / `X-Real-IP` headers instead of `req.ip`.\n\n```ts\nimport { requireAgent, createAgentRouter } from 'imrobot/server'\n\n// Behind a trusted reverse proxy\nconst agentOnly = requireAgent({\n  secret: process.env.IMROBOT_SECRET!,\n  trustProxy: true, // reads X-Forwarded-For for accurate IP-based rate limiting\n  rateLimit: { windowMs: 60_000, maxRequests: 30 },\n})\n\nconst router = createAgentRouter({\n  secret: process.env.IMROBOT_SECRET!,\n  trustProxy: true,\n})\n```\n\n\u003e **Warning:** Only enable `trustProxy` when your server is behind a trusted proxy. Enabling it on a public-facing server allows clients to spoof their IP and bypass rate limiting.\n\n#### Combined handler\n\nAlternatively, use the combined `.handler` property to route both GET and POST requests to a single path:\n\n```ts\nimport { createAgentRouter } from 'imrobot/server'\n\nconst router = createAgentRouter({ secret: process.env.IMROBOT_SECRET! })\n\n// Routes GET → /challenge and POST → /verify under one path\napp.use('/imrobot', router.handler)\n```\n\nThe handler automatically routes based on HTTP method:\n- **GET** → challenge endpoint (returns a signed challenge)\n- **POST** → verify endpoint (verifies answer, returns proof token)\n- **Other methods** → 405 Method Not Allowed\n\n### Rate limiting\n\nBoth `createAgentRouter` and `requireAgent` support built-in **sliding-window** rate limiting to protect against brute-force attacks and request flooding. The rate limiter is in-memory, zero-dependency, and avoids the 2× boundary burst that fixed-window counters allow.\n\n```ts\nimport { createAgentRouter } from 'imrobot/server'\n\nconst router = createAgentRouter({\n  secret: process.env.IMROBOT_SECRET!,\n  rateLimit: {\n    windowMs: 60_000, // 1-minute sliding window\n    maxRequests: 30, // max 30 requests per window per IP\n    onLimitReached: (key) =\u003e console.warn(`Rate limited: ${key}`),\n  },\n})\n```\n\nWhen a client exceeds the limit, they receive a `429 Too Many Requests` response with standard headers. `X-RateLimit-Reset` is in **seconds since epoch** (matching the GitHub / IETF convention), and `Retry-After` is in seconds (RFC 6585):\n\n```\nHTTP/1.1 429 Too Many Requests\nX-RateLimit-Limit: 30\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 1711540860\nRetry-After: 45\n```\n\nThe `RateLimiter` class can also be used standalone:\n\n```ts\nimport { RateLimiter } from 'imrobot/server'\n\nconst limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 10 })\n\nif (!limiter.isAllowed(clientIp)) {\n  // Handle rate limit exceeded\n}\n\nconst status = limiter.getStatus(clientIp)\n// { remaining: 7, resetAt: 1711540860000 }\n```\n\n| Option           | Type            | Default | Description                              |\n| ---------------- | --------------- | ------- | ---------------------------------------- |\n| `windowMs`       | `number`        | `60000` | Sliding window duration in ms            |\n| `maxRequests`    | `number`        | `30`    | Max requests per window per key          |\n| `onLimitReached` | `(key) =\u003e void` | —       | Callback when a client exceeds the limit |\n\nExpired entries are automatically cleaned up to prevent memory leaks in long-running servers.\n\n### Cloudflare Turnstile integration\n\n`createAgentRouter` optionally integrates with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) — a privacy-preserving CAPTCHA alternative — as an extra human-verification layer alongside the imrobot proof-of-work challenge.\n\nWhen configured, the `/verify` endpoint reads the `cf-turnstile-response` header, validates the token against Cloudflare's siteverify API, and stamps the result (`turnstile_verified: true/false`) into the issued proof token. Zero external dependencies — uses Node 18+ native `fetch`.\n\n```ts\nimport { createAgentRouter } from 'imrobot/server'\n\nconst router = createAgentRouter({\n  secret: process.env.IMROBOT_SECRET!,\n  turnstile: {\n    // Load from env — never hardcode. Must be ≥16 non-whitespace characters.\n    secretKey: process.env.TURNSTILE_SECRET_KEY!,\n    tokenHeader: 'cf-turnstile-response', // default, matches Cloudflare widget output\n    required: false,                       // default — non-breaking, won't block existing clients\n    timeoutMs: 5000,                       // siteverify timeout (default 5s; 0 disables)\n  },\n})\n```\n\nThe siteverify call uses an `AbortController` with a configurable `timeoutMs` so a slow Cloudflare response can never hang your verify endpoint. On timeout the result is `{ success: false, errorCodes: ['timeout'] }` and the request is treated like any other Turnstile failure (per your `required` setting).\n\nOn the client side, include the Turnstile widget and pass its token as a request header:\n\n```html\n\u003cscript src=\"https://challenges.cloudflare.com/turnstile/v0/api.js\" async defer\u003e\u003c/script\u003e\n\n\u003cdiv class=\"cf-turnstile\" data-sitekey=\"YOUR_SITE_KEY\" data-callback=\"onTurnstileSuccess\"\u003e\u003c/div\u003e\n\n\u003cscript\u003e\n  function onTurnstileSuccess(token) {\n    // Pass token when submitting the imrobot verify request\n    fetch('/imrobot/verify', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'cf-turnstile-response': token,\n      },\n      body: JSON.stringify({ challenge, answer }),\n    })\n  }\n\u003c/script\u003e\n```\n\nThe standalone `TurnstileVerifier` class and `verifyTurnstileToken` function are also exported for use outside of `createAgentRouter`:\n\n```ts\nimport { TurnstileVerifier, verifyTurnstileToken } from 'imrobot/server'\n\n// Class-based\nconst verifier = new TurnstileVerifier({\n  secretKey: process.env.TURNSTILE_SECRET_KEY!,\n})\nconst result = await verifier.verify(cfToken, clientIp)\n// { success: true, hostname: 'example.com', challenge_ts: '...', errorCodes: [] }\n\n// Standalone function\nconst result = await verifyTurnstileToken(secretKey, cfToken, clientIp)\n```\n\n**Behaviour by `required` flag:**\n\n| `required` | Token absent | Token invalid | Token valid |\n|---|---|---|---|\n| `false` (default) | token issued, no `turnstile_verified` flag | token issued, `turnstile_verified: false` | token issued, `turnstile_verified: true` |\n| `true` | `400 TURNSTILE_TOKEN_REQUIRED` | `400 TURNSTILE_VERIFICATION_FAILED` | token issued, `turnstile_verified: true` |\n\nSet `required: false` initially for a non-breaking rollout — you can enforce it once all clients send the header.\n\n\u003e **Security note:** The secret key must always be loaded from `process.env.TURNSTILE_SECRET_KEY`. Never hardcode it.\n\n### Key rotation (`kid`) and clock skew\n\nBoth `requireAgent` and `createAgentRouter` accept a `keyId` (embedded as `kid` in the JWT header) and a `previousSecrets` array so you can rotate signing secrets without invalidating outstanding tokens:\n\n```ts\nconst router = createAgentRouter({\n  secret: process.env.IMROBOT_SECRET!,         // active key\n  keyId: 'k-2026-04',                          // identifies the active key\n  previousSecrets: [\n    { keyId: 'k-2026-01', secret: process.env.IMROBOT_SECRET_PREV! },\n  ],\n  clockSkewSec: 5,                             // tolerate ±5s drift on iat/nbf/exp (default 5, max 300)\n})\n```\n\n- New tokens are signed with `secret` and stamped with `kid: keyId`.\n- Verification looks up the secret by the token's `kid`. If `kid` is unknown the request is rejected with `403`.\n- Tokens issued before you started setting `keyId` (no `kid` header) verify against the active `secret`.\n\nRoll a key by deploying with the new `secret`/`keyId` while moving the previous one into `previousSecrets`. Once your max token TTL has elapsed, you can drop the old entry.\n\n### Invisible verification (zero-UI)\n\nFor agents that need to verify themselves programmatically without any UI:\n\n```ts\nimport { invisibleVerify } from 'imrobot/core'\n\nconst result = await invisibleVerify({\n  challengeUrl: 'https://api.example.com/imrobot/challenge',\n  verifyUrl: 'https://api.example.com/imrobot/verify',\n  agentId: 'my-bot-v1',\n  maxRetries: 3,\n})\n\nif (result.success) {\n  // Use result.proofToken in X-Agent-Proof header\n  fetch('/api/protected', {\n    headers: { 'X-Agent-Proof': result.proofToken! },\n  })\n}\n```\n\n### CLI\n\nBuilt-in CLI for testing, benchmarking, and inspecting challenges:\n\n```bash\nnpx imrobot challenge --difficulty hard\nnpx imrobot solve --difficulty medium\nnpx imrobot benchmark --count 1000\nnpx imrobot info\n```\n\n### Agent discovery (`.well-known/imrobot.json`)\n\nInspired by the [A2A Agent Card](https://google.github.io/A2A/) pattern, imrobot supports a discovery endpoint that lets AI agents automatically find and interact with your imrobot-protected service.\n\n```ts\nimport { createDiscoveryHandler, createAgentRouter, requireAgent } from 'imrobot/server'\n\n// Mount the discovery endpoint\nconst discovery = createDiscoveryHandler({\n  challengePath: '/imrobot',\n  name: 'My Agent API',\n  description: 'Agent-verified data service',\n})\napp.get('/.well-known/imrobot.json', discovery)\n\n// Mount challenge/verify as usual\nconst router = createAgentRouter({ secret: process.env.IMROBOT_SECRET! })\napp.get('/imrobot/challenge', router.challenge)\napp.post('/imrobot/verify', router.verify)\n```\n\nAgents fetch `/.well-known/imrobot.json` and receive a structured document describing the protocol, endpoint paths, supported difficulty levels, and step-by-step instructions for completing verification:\n\n```json\n{\n  \"protocol\": \"imrobot\",\n  \"version\": \"1.0\",\n  \"endpoints\": {\n    \"challenge\": \"/imrobot/challenge\",\n    \"verify\": \"/imrobot/verify\",\n    \"proofHeader\": \"X-Agent-Proof\"\n  },\n  \"difficulties\": [\"easy\", \"medium\", \"hard\"],\n  \"instructions\": \"1. GET the challenge endpoint...\"\n}\n```\n\nFor framework-agnostic usage (Hono, Koa, Fastify, etc.), use `buildDiscoveryDocument()` directly:\n\n```ts\nimport { buildDiscoveryDocument } from 'imrobot/server'\n\nconst doc = buildDiscoveryDocument({ challengePath: '/imrobot' })\n// Serve `doc` as JSON at /.well-known/imrobot.json\n```\n\n`createDiscoveryHandler` sets sensible defaults so third-party agents can fetch the document from any origin and cache it:\n\n```\nContent-Type: application/json; charset=utf-8\nCache-Control: public, max-age=3600\nAccess-Control-Allow-Origin: *\nVary: Origin\n```\n\nOverride either with `cacheControl` / `corsOrigin`. Pass `null` to omit:\n\n```ts\ncreateDiscoveryHandler({\n  cacheControl: 'no-store',                  // disable caching\n  corsOrigin: 'https://agents.example.com',  // restrict CORS\n})\n```\n\n## Screenshot protection\n\nThe challenge text is **blurred by default** and only revealed when the user hovers over it. This defeats screenshot-based attacks (screen capture tools, CDP screenshots, PrintScreen) since the captured image shows only blurred content.\n\nAn additional JavaScript shield detects screenshot shortcuts (PrintScreen, Cmd+Shift+3/4/5, Ctrl+Shift+S) and window blur/visibility changes, applying an extra blur layer that overrides even the hover state.\n\nCombined with the hidden nonce (not displayed visually) and TTL expiry, this makes screenshot+OCR workflows ineffective — even if the blur were bypassed, the nonce is missing from the visual output.\n\n\u003e **Note:** AI agents are unaffected — they read challenge data from the DOM, not from the screen.\n\n### Using the shield in vanilla JS\n\nThe screenshot shield is exported for use outside the bundled components:\n\n```js\nimport { setupScreenshotShield } from 'imrobot'\n\nconst cleanup = setupScreenshotShield((shielded) =\u003e {\n  // shielded: true when a screenshot attempt is detected\n  // automatically resets to false after 1.2s\n})\n\n// Call cleanup() to remove event listeners\n```\n\n## How agents interact with it\n\nAI agents read the challenge data directly from the DOM via the `data-imrobot-challenge` attribute — they never need to \"see\" the visual text, so blur has no effect on them.\n\n1. **Read the challenge** from `data-imrobot-challenge` attribute (JSON)\n2. **Execute the pipeline** — each operation is a deterministic transform\n3. **Submit the answer** via the input field or programmatically\n\n```js\n// Agent reads challenge from DOM (unaffected by blur)\nconst el = document.querySelector('[data-imrobot-challenge]')\nconst challenge = JSON.parse(el.dataset.imrobotChallenge)\n\n// Agent solves it (or implement the pipeline yourself)\nimport { solveChallenge } from 'imrobot/core'\nconst answer = solveChallenge(challenge)\n\n// Agent fills in the answer and clicks verify\nconst input = el.querySelector('input')\ninput.value = answer\ninput.dispatchEvent(new Event('input', { bubbles: true }))\nel.querySelector('button').click()\n```\n\n## Natural-language challenge formatting\n\nBy default, challenges display operations in programmatic syntax (`reverse()`, `caesar(7)`). For deployments where you want to make regex-based scraping of the display text harder, use the natural-language formatting functions:\n\n```ts\nimport { formatOperationNL, formatPipelineNL } from 'imrobot/core'\n\nconst challenge = generateChallenge({ difficulty: 'hard' })\n\n// Each call produces randomised phrasing:\nconsole.log(formatPipelineNL(challenge.visibleSeed, challenge.pipeline))\n// \"Begin with the text: \"a7f3...\"\n//  Step 1: Flip the string backwards\n//  Then 2: Shift every letter 7 positions in the alphabet\n//  Next 3: Bitwise-XOR every character with the value 42\n//  ...\"\n```\n\nEvery operation has 3–4 distinct phrasings that are randomly selected on each call, so the display text varies unpredictably. Agents must parse the JSON `pipeline` (unaffected), while regex scraping of the visual text becomes unreliable.\n\n\u003e **Tip:** The original programmatic functions `formatOperation` / `formatPipeline` remain unchanged — use them when you need a stable, deterministic format.\n\n## Operations reference\n\n### String operations\n\n| Operation            | Description             | Example                  |\n| -------------------- | ----------------------- | ------------------------ |\n| `reverse()`          | Reverse the string      | `\"abc\"` → `\"cba\"`        |\n| `to_upper()`         | Convert to uppercase    | `\"abc\"` → `\"ABC\"`        |\n| `to_lower()`         | Convert to lowercase    | `\"ABC\"` → `\"abc\"`        |\n| `base64_encode()`    | Base64 encode           | `\"hello\"` → `\"aGVsbG8=\"` |\n| `rot13()`            | ROT13 cipher            | `\"hello\"` → `\"uryyb\"`    |\n| `hex_encode()`       | Hex encode each char    | `\"AB\"` → `\"4142\"`        |\n| `sort_chars()`       | Sort characters         | `\"dcba\"` → `\"abcd\"`      |\n| `char_code_sum()`    | Sum of char codes       | `\"AB\"` → `\"131\"`         |\n| `substring(s, e)`    | Extract substring       | `\"abcdef\"` → `\"cde\"`     |\n| `repeat(n)`          | Repeat string n times   | `\"ab\"` → `\"ababab\"`      |\n| `replace(s, r)`      | Replace all occurrences | `\"aab\"` → `\"xxb\"`        |\n| `pad_start(len, ch)` | Pad start to length     | `\"abc\"` → `\"000abc\"`     |\n| `vowel_count()`      | Count vowels            | `\"hello\"` → `\"2\"`        |\n| `consonant_extract()`| Extract consonants only | `\"hello\"` → `\"hll\"`      |\n| `run_length_encode()` | Run-length encode      | `\"aaabb\"` → `\"3a2b\"`     |\n| `atbash()`           | Atbash cipher (a↔z)    | `\"abc\"` → `\"zyx\"`        |\n\n### Byte \u0026 cipher operations\n\n| Operation            | Description                           | Example                         |\n| -------------------- | ------------------------------------- | ------------------------------- |\n| `caesar(shift)`      | Caesar cipher with configurable shift | `\"abc\"` + shift 1 → `\"bcd\"`     |\n| `xor_encode(key)`    | XOR each byte with key                | `\"AB\"` + key 1 → `\"@C\"`         |\n| `count_chars(char)`  | Count occurrences of a char           | `\"aababc\"` + char `\"a\"` → `\"3\"` |\n| `slice_alternate()`  | Keep every other character            | `\"abcdef\"` → `\"ace\"`            |\n| `fnv1a_hash()`       | FNV-1a hash of the string             | `\"test\"` → `\"bc2c0be9\"`         |\n| `length()`           | String length as string               | `\"hello\"` → `\"5\"`               |\n| `fnv1a_cascade()`    | Cascaded FNV-1a, 8 rounds → 64 hex chars | deterministic hex output     |\n| `sha256_hash()`      | **Misnomer — alias of `fnv1a_cascade()`.** Kept for wire-format compatibility. NOT real SHA-256. | identical output to `fnv1a_cascade` |\n| `byte_xor(key[])`    | XOR each byte with key array (cycling) | byte-level encryption          |\n| `hash_chain(rounds)` | Iterated FNV-1a hash                  | cascaded hashing                |\n| `nibble_swap()`      | Swap high/low nibbles per byte        | `0xAB` → `0xBA`                 |\n| `bit_rotate(bits)`   | Rotate bits left within byte          | bitwise rotation                |\n\n\u003e **About `sha256_hash`:** The op name is preserved for wire-format compatibility with already-issued challenges, but the implementation has always been FNV-1a cascaded 8 times — not RFC 6234 SHA-256. Use `fnv1a_cascade` in new code; both names produce identical output.\n\n## Configuration\n\n| Prop         | Type                              | Default        | Description                                                      |\n| ------------ | --------------------------------- | -------------- | ---------------------------------------------------------------- |\n| `difficulty` | `'easy' \\| 'medium' \\| 'hard'`    | `'medium'`     | Number and complexity of operations                              |\n| `theme`      | `'light' \\| 'dark'`               | `'light'`      | Color theme                                                      |\n| `size`       | `'compact' \\| 'standard'`         | `'standard'`   | Widget size — `compact` for smaller footprint (320px). Supported by all four adapters (React, Vue, Svelte, Web Component). |\n| `ttl`        | `number`                          | per-difficulty | Challenge time-to-live in ms (easy: 30s, medium: 20s, hard: 15s) |\n| `onVerified` | `(token) =\u003e void`                 | —              | Callback on successful verification                              |\n| `onError`    | `(error) =\u003e void`                 | —              | Callback on failed verification                                  |\n\n### Difficulty levels\n\n- **easy**: 2-3 simple operations (reverse, case, sort, length, slice_alternate, vowel_count, atbash)\n- **medium**: 3-5 operations including encoding, extraction, caesar, char counting, consonant_extract, run_length_encode\n- **hard**: 5-7 operations including XOR encoding, hashing, replacement, padding, SHA-256, byte XOR, hash chains, nibble swap, and bit rotate\n\n## Server verification\n\nFor production deployments, use the server SDK (`imrobot/server`) instead of client-side-only verification. The server SDK uses HMAC-SHA256 to sign challenges, providing tamper-proof, stateless, replay-resistant verification with zero database overhead.\n\n```ts\nimport { createVerifier } from 'imrobot/server'\n\nconst verifier = createVerifier({\n  secret: process.env.IMROBOT_SECRET!, // HMAC secret (min 16 chars)\n  difficulty: 'hard',\n  ttl: 10_000, // optional: override default TTL\n})\n\n// Generate → send to client → client solves → verify answer\nconst challenge = await verifier.generate()\nconst result = await verifier.verify(challenge, agentAnswer)\n```\n\n#### Replay protection\n\nTo prevent the same challenge from being verified more than once, pass a `ChallengeReplayGuard` instance to `createVerifier()`:\n\n```ts\nimport { createVerifier, ChallengeReplayGuard } from 'imrobot/server'\n\nconst replayGuard = new ChallengeReplayGuard({\n  maxAge: 5 * 60 * 1000,     // track IDs for 5 minutes\n  cleanupInterval: 60_000,   // purge expired entries every minute\n})\n\nconst verifier = createVerifier({\n  secret: process.env.IMROBOT_SECRET!,\n  difficulty: 'medium',\n  replayGuard, // enables replay detection\n})\n\n// First verify() succeeds; second verify() with the same challenge\n// returns { valid: false, reason: 'replay' }\n```\n\nThe replay guard is in-memory with automatic expiry cleanup and `unref()`'d timers, so it won't keep the process alive. Call `replayGuard.destroy()` on shutdown to clear the cleanup interval.\n\n### ChallengeAnalytics\n\n`ChallengeAnalytics` (exported from `imrobot/server`) is a lightweight, in-memory metrics tracker for monitoring challenge activity — generation rates, verification rates, solve-time percentiles, and failure-reason distributions. Zero external dependencies, memory-bounded (sliding window of configurable size).\n\n```ts\nimport { ChallengeAnalytics } from 'imrobot/server'\n\nconst analytics = new ChallengeAnalytics({\n  maxSamples: 1000,          // solve-time samples kept per difficulty (default: 1000)\n  trackFailureReasons: true, // track per-reason failure counts (default: true)\n})\n\n// Record events as they happen\nanalytics.recordGenerated('medium')\nanalytics.recordVerified('medium', 142, false) // 142ms, not suspicious\nanalytics.recordFailed('hard', 'wrong_answer')\n\n// Get a full snapshot\nconst stats = analytics.getStats()\nconsole.log(stats.summary.verificationRate)        // 0.5 (50%)\nconsole.log(stats.byDifficulty.medium.avgSolveTimeMs) // 142\nconsole.log(stats.byDifficulty.hard.failureReasons)   // { wrong_answer: 1 }\n\n// Export for dashboards / structured logging\nconsole.log(JSON.stringify(analytics.toJSON(), null, 2))\n\n// Periodic rotation — reset all counters\nanalytics.reset()\n```\n\n`getStats()` returns an `AnalyticsSnapshot` with:\n- **`summary`** — aggregate totals: `totalGenerated`, `totalVerified`, `totalFailed`, `totalExpired`, `totalSuspicious`, `verificationRate`, `avgSolveTimeMs`, `uptimeMs`\n- **`byDifficulty`** — per-difficulty `DifficultyStats` with min/max/p95 solve times and per-reason failure counts\n- **`collectedAt`** — Unix timestamp of the snapshot\n\n### VerifyResult\n\nThe `verify()` method returns a `VerifyResult`:\n\n```ts\ninterface VerifyResult {\n  valid: boolean\n  reason?: 'expired' | 'invalid_hmac' | 'wrong_answer' | 'tampered' | 'replay'\n  elapsed?: number // ms since challenge was created\n  suspicious?: boolean // true if response was unusually slow\n}\n```\n\n## Token\n\nOn successful verification, `onVerified` receives an `ImRobotToken`:\n\n```ts\ninterface ImRobotToken {\n  challengeId: string // Unique challenge identifier\n  answer: string // The correct answer\n  timestamp: number // Verification timestamp\n  elapsed: number // Time taken to solve (ms)\n  suspicious: boolean // true if elapsed \u003e 5s (possible human relay)\n  signature: string // Verification signature\n}\n```\n\n## Adaptive difficulty\n\nThe adaptive difficulty engine auto-adjusts challenge difficulty per agent based on behavioral patterns — inspired by Arkose Labs (FunCaptcha) progressive difficulty and reCAPTCHA v3 risk scoring.\n\n```ts\nimport { AdaptiveDifficulty } from 'imrobot/core'\n\nconst adaptive = new AdaptiveDifficulty({\n  initialDifficulty: 'medium',\n  escalateAfterFailures: 2,  // escalate after 2 consecutive failures\n  relaxAfterSuccesses: 5,    // relax after 5 consecutive successes\n})\n\n// Record outcomes as agents solve challenges\nadaptive.recordAttempt('agent_123', { success: true, solveTimeMs: 42 })\n\n// Get recommended difficulty for next challenge\nconst diff = adaptive.getDifficulty('agent_123') // 'medium' | 'easy' | 'hard'\n\n// Get risk assessment (0-1 score with breakdown)\nconst risk = adaptive.getRiskAssessment('agent_123')\n// { score: 0.15, level: 'low', factors: { failureRate, abnormalTiming, rapidAttempts, inconsistentTiming } }\n\n// Get just the numeric score (shorthand)\nconst score = adaptive.getRiskScore('agent_123') // 0.15\n```\n\nThe risk score weighs four factors: failure rate (35%), abnormal timing (25%), rapid-fire attempts (25%), and inconsistent solve times (15%). Risk levels: `low` | `medium` | `high` | `critical`.\n\n## AI image challenges (experimental)\n\nFoundation for AI-generated image verification challenges. Pre-generate pools of images with known ground truth, then serve them as additional challenge layers.\n\n```ts\nimport { ImageChallengePool } from 'imrobot/core'\n\n// Option 1: Static provider (pre-generated images, no API needed)\nconst pool = new ImageChallengePool({\n  provider: {\n    type: 'static',\n    images: [\n      { imageUrl: '/img/kitchen-3-apples.png', type: 'object_count', question: 'How many red apples?', answer: '3' },\n      { imageUrl: '/img/park-bench.png', type: 'spatial_reasoning', question: 'What is to the left of the bench?', answer: 'tree' },\n    ],\n  },\n})\n\n// Option 2: Custom provider (bring your own AI image generator)\nconst pool2 = new ImageChallengePool({\n  provider: {\n    type: 'custom',\n    generate: async (prompt) =\u003e {\n      const result = await myImageGenerator(prompt)\n      return { imageUrl: result.url }\n    },\n  },\n  poolSize: 100,\n  challengeTypes: ['object_count', 'spatial_reasoning', 'color_identification'],\n  rotationIntervalMs: 3_600_000, // rotate pool every hour\n})\n\nawait pool.initialize()\nconst challenge = pool.getChallenge()\nconst isCorrect = pool.verifyAnswer(challenge.id, userAnswer)\n```\n\nSix challenge types are supported: `object_count`, `spatial_reasoning`, `color_identification`, `scene_description`, `text_recognition`, and `odd_one_out`. Each type includes built-in prompt templates that generate prompts with known ground truth.\n\n\u003e **Note:** Direct OpenAI/Stability AI API integration is planned. For now, use the `custom` or `static` provider.\n\n## Contributing\n\nContributions are welcome! Feel free to open issues for bug reports or feature requests, or submit pull requests.\n\n```bash\ngit clone https://github.com/leopechnicki/im_robot.git\ncd im_robot\nnpm install\nnpm test\n```\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fleopechnicki%2Fim_robot","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fleopechnicki%2Fim_robot","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fleopechnicki%2Fim_robot/lists"}