Reverse-CAPTCHA for AI agents โ verify bots, not humans.
Live Demo ยท npm ยท Dev.to Article
Traditional CAPTCHAs prove you're human. But what about the opposite?
As AI agents become first-class web citizens โ browsing, booking, purchasing, automating โ some systems need to verify their visitors are legitimate AI agents, not humans trying to bypass agent-only access. Think agent-facing APIs, AI-only platforms, or multi-agent authentication.
imrobot flips the CAPTCHA model: it generates deterministic challenge pipelines that are trivial for any LLM or programmatic agent to solve (< 1 second), but impractical for humans to work through manually.
imrobot 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.
seed: "a7f3b2c1d4e5f609"
1. reverse()
2. caesar(7)
3. xor_encode(42)
4. fnv1a_hash()
5. to_upper()
The challenge data is embedded in the DOM via data-imrobot-challenge attribute as structured JSON, making it trivially parseable by any agent.
npm install imrobot
import { ImRobot } from 'imrobot/react'
function App() {
return (
<ImRobot
difficulty="medium"
theme="light"
onVerified={(token) => {
console.log('Robot verified!', token)
}}
/>
)
}
<script setup>
import { ImRobot } from 'imrobot/vue'
function handleVerified(token) {
console.log('Robot verified!', token)
}
</script>
<template>
<ImRobot difficulty="medium" theme="light" @verified="handleVerified" />
</template>
<script>
import ImRobot from 'imrobot/svelte'
</script>
<ImRobot
difficulty="medium"
theme="light"
onVerified={(token) => console.log('Robot verified!', token)}
/>
<script type="module">
import { register } from 'imrobot/web-component'
register() // registers <imrobot-widget>
</script>
<imrobot-widget difficulty="medium" theme="light"></imrobot-widget>
<script>
document.querySelector('imrobot-widget').addEventListener('imrobot-verified', (e) => {
console.log('Robot verified!', e.detail)
})
</script>
import { generateChallenge, solveChallenge, verifyAnswer } from 'imrobot/core'
const challenge = generateChallenge({ difficulty: 'medium' })
const answer = solveChallenge(challenge)
const isValid = verifyAnswer(challenge, answer) // true
For production use, the server SDK provides tamper-proof, stateless challenge verification using HMAC-SHA256. No database required โ the cryptographic signature ensures integrity.
import { createVerifier } from 'imrobot/server'
const verifier = createVerifier({
secret: process.env.IMROBOT_SECRET!, // min 16 chars
difficulty: 'medium',
})
// API route: generate a signed challenge
app.get('/api/challenge', async (req, res) => {
const challenge = await verifier.generate()
res.json(challenge) // includes HMAC signature
})
// API route: verify agent's answer (stateless)
app.post('/api/verify', async (req, res) => {
const { challenge, answer } = req.body
const result = await verifier.verify(challenge, answer)
// result: { valid: true, elapsed: 42, suspicious: false }
// or: { valid: false, reason: 'wrong_answer' | 'expired' | 'invalid_hmac' | 'tampered' }
res.json(result)
})
The server verifier checks in order: HMAC signature validity (challenge and pipeline not tampered), expiration (challenge not expired), and answer correctness (pipeline re-executed). A different secret on a different server will reject the challenge โ preventing cross-site replay attacks.
Protect your API endpoints with framework-agnostic middleware. Verified agents receive a JWT-like Proof-of-Agent token (HMAC-SHA256 signed) that they pass via X-Agent-Proof header on subsequent requests.
import { requireAgent, createAgentRouter } from 'imrobot/server'
// Mount challenge/verify endpoints with rate limiting
const router = createAgentRouter({
secret: process.env.IMROBOT_SECRET!,
rateLimit: { windowMs: 60_000, maxRequests: 30 },
})
app.get('/imrobot/challenge', router.challenge)
app.post('/imrobot/verify', router.verify)
// Protect routes โ only verified agents can access
const agentOnly = requireAgent({
secret: process.env.IMROBOT_SECRET!,
rateLimit: { windowMs: 60_000, maxRequests: 30 },
})
app.get('/api/data', agentOnly, (req, res) => {
res.json({ agent: req.agentProof })
})
Alternatively, use the combined .handler property to route both GET and POST requests to a single path:
import { createAgentRouter } from 'imrobot/server'
const router = createAgentRouter({ secret: process.env.IMROBOT_SECRET! })
// Routes GET โ /challenge and POST โ /verify under one path
app.use('/imrobot', router.handler)
The handler automatically routes based on HTTP method:
Both createAgentRouter and requireAgent support built-in rate limiting to protect against brute-force attacks and request flooding. The rate limiter is in-memory with zero external dependencies.
import { createAgentRouter } from 'imrobot/server'
const router = createAgentRouter({
secret: process.env.IMROBOT_SECRET!,
rateLimit: {
windowMs: 60_000, // 1-minute sliding window
maxRequests: 30, // max 30 requests per window per IP
onLimitReached: (key) => console.warn(`Rate limited: ${key}`),
},
})
When a client exceeds the limit, they receive a 429 Too Many Requests response with standard headers:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711540860
Retry-After: 45
The RateLimiter class can also be used standalone:
import { RateLimiter } from 'imrobot/server'
const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 10 })
if (!limiter.isAllowed(clientIp)) {
// Handle rate limit exceeded
}
const status = limiter.getStatus(clientIp)
// { remaining: 7, resetAt: 1711540860000 }
| Option | Type | Default | Description |
|---|---|---|---|
windowMs |
number |
60000 |
Sliding window duration in ms |
maxRequests |
number |
30 |
Max requests per window per key |
onLimitReached |
(key) => void |
โ | Callback when a client exceeds the limit |
Expired entries are automatically cleaned up to prevent memory leaks in long-running servers.
For agents that need to verify themselves programmatically without any UI:
import { invisibleVerify } from 'imrobot/core'
const result = await invisibleVerify({
challengeUrl: 'https://api.example.com/imrobot/challenge',
verifyUrl: 'https://api.example.com/imrobot/verify',
agentId: 'my-bot-v1',
maxRetries: 3,
})
if (result.success) {
// Use result.proofToken in X-Agent-Proof header
fetch('/api/protected', {
headers: { 'X-Agent-Proof': result.proofToken! },
})
}
Built-in CLI for testing, benchmarking, and inspecting challenges:
npx imrobot challenge --difficulty hard
npx imrobot solve --difficulty medium
npx imrobot benchmark --count 1000
npx imrobot info
.well-known/imrobot.json)Inspired by the A2A Agent Card pattern, imrobot supports a discovery endpoint that lets AI agents automatically find and interact with your imrobot-protected service.
import { createDiscoveryHandler, createAgentRouter, requireAgent } from 'imrobot/server'
// Mount the discovery endpoint
const discovery = createDiscoveryHandler({
challengePath: '/imrobot',
name: 'My Agent API',
description: 'Agent-verified data service',
})
app.get('/.well-known/imrobot.json', discovery)
// Mount challenge/verify as usual
const router = createAgentRouter({ secret: process.env.IMROBOT_SECRET! })
app.get('/imrobot/challenge', router.challenge)
app.post('/imrobot/verify', router.verify)
Agents 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:
{
"protocol": "imrobot",
"version": "1.0",
"endpoints": {
"challenge": "/imrobot/challenge",
"verify": "/imrobot/verify",
"proofHeader": "X-Agent-Proof"
},
"difficulties": ["easy", "medium", "hard"],
"instructions": "1. GET the challenge endpoint..."
}
For framework-agnostic usage (Hono, Koa, Fastify, etc.), use buildDiscoveryDocument() directly:
import { buildDiscoveryDocument } from 'imrobot/server'
const doc = buildDiscoveryDocument({ challengePath: '/imrobot' })
// Serve `doc` as JSON at /.well-known/imrobot.json
The 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.
An 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.
Combined 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.
Note: AI agents are unaffected โ they read challenge data from the DOM, not from the screen.
The screenshot shield is exported for use outside the bundled components:
import { setupScreenshotShield } from 'imrobot'
const cleanup = setupScreenshotShield((shielded) => {
// shielded: true when a screenshot attempt is detected
// automatically resets to false after 1.2s
})
// Call cleanup() to remove event listeners
AI 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.
data-imrobot-challenge attribute (JSON)// Agent reads challenge from DOM (unaffected by blur)
const el = document.querySelector('[data-imrobot-challenge]')
const challenge = JSON.parse(el.dataset.imrobotChallenge)
// Agent solves it (or implement the pipeline yourself)
import { solveChallenge } from 'imrobot/core'
const answer = solveChallenge(challenge)
// Agent fills in the answer and clicks verify
const input = el.querySelector('input')
input.value = answer
input.dispatchEvent(new Event('input', { bubbles: true }))
el.querySelector('button').click()
By 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:
import { formatOperationNL, formatPipelineNL } from 'imrobot/core'
const challenge = generateChallenge({ difficulty: 'hard' })
// Each call produces randomised phrasing:
console.log(formatPipelineNL(challenge.visibleSeed, challenge.pipeline))
// "Begin with the text: "a7f3..."
// Step 1: Flip the string backwards
// Then 2: Shift every letter 7 positions in the alphabet
// Next 3: Bitwise-XOR every character with the value 42
// ..."
Every 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.
Tip: The original programmatic functions
formatOperation/formatPipelineremain unchanged โ use them when you need a stable, deterministic format.
| Operation | Description | Example |
|---|---|---|
reverse() |
Reverse the string | "abc" โ "cba" |
to_upper() |
Convert to uppercase | "abc" โ "ABC" |
to_lower() |
Convert to lowercase | "ABC" โ "abc" |
base64_encode() |
Base64 encode | "hello" โ "aGVsbG8=" |
rot13() |
ROT13 cipher | "hello" โ "uryyb" |
hex_encode() |
Hex encode each char | "AB" โ "4142" |
sort_chars() |
Sort characters | "dcba" โ "abcd" |
char_code_sum() |
Sum of char codes | "AB" โ "131" |
substring(s, e) |
Extract substring | "abcdef" โ "cde" |
repeat(n) |
Repeat string n times | "ab" โ "ababab" |
replace(s, r) |
Replace all occurrences | "aab" โ "xxb" |
pad_start(len, ch) |
Pad start to length | "abc" โ "000abc" |
vowel_count() |
Count vowels | "hello" โ "2" |
consonant_extract() |
Extract consonants only | "hello" โ "hll" |
run_length_encode() |
Run-length encode | "aaabb" โ "3a2b" |
atbash() |
Atbash cipher (aโz) | "abc" โ "zyx" |
| Operation | Description | Example |
|---|---|---|
caesar(shift) |
Caesar cipher with configurable shift | "abc" + shift 1 โ "bcd" |
xor_encode(key) |
XOR each byte with key | "AB" + key 1 โ "@C" |
count_chars(char) |
Count occurrences of a char | "aababc" + char "a" โ "3" |
slice_alternate() |
Keep every other character | "abcdef" โ "ace" |
fnv1a_hash() |
FNV-1a hash of the string | "test" โ "bc2c0be9" |
length() |
String length as string | "hello" โ "5" |
sha256_hash() |
SHA-256 hash (sync FNV-based) | deterministic hex output |
byte_xor(key[]) |
XOR each byte with key array | byte-level encryption |
hash_chain(rounds) |
Iterated FNV-1a hash | cascaded hashing |
nibble_swap() |
Swap high/low nibbles per byte | 0xAB โ 0xBA |
bit_rotate(bits) |
Rotate bits left within byte | bitwise rotation |
| Prop | Type | Default | Description |
|---|---|---|---|
difficulty |
'easy' | 'medium' | 'hard' |
'medium' |
Number and complexity of operations |
theme |
'light' | 'dark' |
'light' |
Color theme |
size |
'compact' | 'standard' |
'standard' |
Widget size โ compact for smaller footprint (320px) |
ttl |
number |
per-difficulty | Challenge time-to-live in ms (easy: 30s, medium: 20s, hard: 15s) |
onVerified |
(token) => void |
โ | Callback on successful verification |
onError |
(error) => void |
โ | Callback on failed verification |
For 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.
import { createVerifier } from 'imrobot/server'
const verifier = createVerifier({
secret: process.env.IMROBOT_SECRET!, // HMAC secret (min 16 chars)
difficulty: 'hard',
ttl: 10_000, // optional: override default TTL
})
// Generate โ send to client โ client solves โ verify answer
const challenge = await verifier.generate()
const result = await verifier.verify(challenge, agentAnswer)
The verify() method returns a VerifyResult:
interface VerifyResult {
valid: boolean
reason?: 'expired' | 'invalid_hmac' | 'wrong_answer' | 'tampered'
elapsed?: number // ms since challenge was created
suspicious?: boolean // true if response was unusually slow
}
On successful verification, onVerified receives an ImRobotToken:
interface ImRobotToken {
challengeId: string // Unique challenge identifier
answer: string // The correct answer
timestamp: number // Verification timestamp
elapsed: number // Time taken to solve (ms)
suspicious: boolean // true if elapsed > 5s (possible human relay)
signature: string // Verification signature
}
The 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.
import { AdaptiveDifficulty } from 'imrobot/core'
const adaptive = new AdaptiveDifficulty({
initialDifficulty: 'medium',
escalateAfterFailures: 2, // escalate after 2 consecutive failures
relaxAfterSuccesses: 5, // relax after 5 consecutive successes
})
// Record outcomes as agents solve challenges
adaptive.recordAttempt('agent_123', { success: true, solveTimeMs: 42 })
// Get recommended difficulty for next challenge
const diff = adaptive.getDifficulty('agent_123') // 'medium' | 'easy' | 'hard'
// Get risk assessment (0-1 score with breakdown)
const risk = adaptive.getRiskAssessment('agent_123')
// { score: 0.15, level: 'low', factors: { failureRate, abnormalTiming, rapidAttempts, inconsistentTiming } }
The 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.
Foundation for AI-generated image verification challenges. Pre-generate pools of images with known ground truth, then serve them as additional challenge layers.
import { ImageChallengePool } from 'imrobot/core'
// Option 1: Static provider (pre-generated images, no API needed)
const pool = new ImageChallengePool({
provider: {
type: 'static',
images: [
{ imageUrl: '/img/kitchen-3-apples.png', type: 'object_count', question: 'How many red apples?', answer: '3' },
{ imageUrl: '/img/park-bench.png', type: 'spatial_reasoning', question: 'What is to the left of the bench?', answer: 'tree' },
],
},
})
// Option 2: Custom provider (bring your own AI image generator)
const pool2 = new ImageChallengePool({
provider: {
type: 'custom',
generate: async (prompt) => {
const result = await myImageGenerator(prompt)
return { imageUrl: result.url }
},
},
poolSize: 100,
challengeTypes: ['object_count', 'spatial_reasoning', 'color_identification'],
rotationIntervalMs: 3_600_000, // rotate pool every hour
})
await pool.initialize()
const challenge = pool.getChallenge()
const isCorrect = pool.verifyAnswer(challenge.id, userAnswer)
Six 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.
Note: Direct OpenAI/Stability AI API integration is planned. For now, use the
customorstaticprovider.
Contributions are welcome! Feel free to open issues for bug reports or feature requests, or submit pull requests.
git clone https://github.com/leopechnicki/im_robot.git
cd im_robot
npm install
npm test
MIT