Pairing & OTP System
The Pairing & OTP system controls how new Telegram users gain access to CamelAGI bots. When an unauthorized user messages a bot, a pairing request is created. An admin must approve the request, which generates a one-time password (OTP). The user then enters the OTP in the bot chat to complete verification. This two-step flow ensures that only admin-authorized users can interact with the bot.
There is also a separate Bot Approval flow that governs whether new agent bots themselves are allowed to start polling Telegram.
Table of Contents
- Complete Pairing Flow
- OTP Generation and Verification
- Security Details
- Three Approval Methods
- Admin Bot Middleware
- Agent Bot Middleware
- Bot Approval Flow
- Notification System
- Storage Format
- REST API Endpoints
- CLI Usage
Complete Pairing Flow
Step-by-Step
- Unauthorized user messages a bot. The middleware in either
admin-bot.tsoragent-bot.tschecksisUserAllowed(). The user is not found. - Pairing request created.
createPairingRequest()generates a 6-character alphanumeric code and persists the request to~/.camelagi/pairing.jsonwith status"pending". - User sees confirmation. The bot replies with the pairing code (e.g.,
Code: H7KM3R) and tells the user to wait for admin approval. - Admin is notified. For agent bots,
notifyAdminOfPairing()sends an inline-keyboard message to all admin bot users with Approve/Deny buttons. For admin bots, the user simply sees their code and waits. - Admin approves. Via one of three methods (REST API, CLI, admin bot inline button),
approveRequest()is called. This generates a 5-digit OTP, sets status to"otp_pending", and recordsotpCreatedAt. - User is notified to enter OTP.
notifyUserOtpRequired()sends a message to the user’s chat telling them to enter the verification code. - Admin receives the OTP. The OTP is displayed to the admin (in the app, CLI output, or inline button edit) so they can communicate it to the user out-of-band.
- User enters the OTP. On the next message from the user, the middleware detects
status === "otp_pending"and checks the input against the stored OTP viaverifyOtp(). - Verification succeeds. The user’s Telegram ID is added to
allowedUsersinconfig.yaml, the pairing request is removed, and the user’s ID is cached in the in-memoryotpVerifiedUsersSet. The user can now use the bot.
Flow Diagram
User Bot Middleware Admin Storage
| | | |
|-- sends message ----------->| | |
| |-- isUserAllowed? NO | |
| |-- createPairingRequest ----------------------->| pairing.json
|<-- "Code: H7KM3R" ---------| | | status: pending
| |-- notifyAdminOfPairing ------->| |
| | | |
| | approveRequest <-----| |
| | | (generates OTP) ----->| status: otp_pending
| | |<-- OTP: 48271 |
|<-- "Enter 5-digit code" ----|<- notifyUserOtpRequired | |
| | | |
|-- "48271" ----------------->| | |
| |-- verifyOtp(48271) --->| |
| | OTP matches | |
| |-- addUserToAllowedList ----------------------->| config.yaml
| |-- remove request ------------------------------>| pairing.json
|<-- "Verification complete" -| | |
| | | |
|-- (future messages) ------->|-- isUserAllowed? YES | |
|<-- (normal response) -------| | |
OTP Generation and Verification
Generation
- Generated by
generateOtp()usingcrypto.randomInt(10000, 99999). - Produces a 5-digit numeric string (range 10000-99999).
- Created at approval time (not at request time).
Verification (verifyOtp)
The function performs checks in this order:
- Lookup: Finds a request matching
userId,agentId, andstatus === "otp_pending". If not found, returns{ ok: false, reason: "not_found" }. - Expiry check: If
Date.now() - otpCreatedAt >= OTP_TTL_MS(5 minutes), the request is deleted and returns{ ok: false, reason: "expired" }. - Brute-force check: If
otpAttempts >= MAX_OTP_ATTEMPTS(5), the request is deleted and returns{ ok: false, reason: "locked" }. - OTP comparison: If the trimmed input does not match the stored OTP, increments
otpAttemptsand returns{ ok: false, reason: "wrong" }. - Success: Calls
addUserToAllowedList()first (atomic: user gets access even if cleanup fails), then marks the request as"completed"and removes it.
OtpResult Type
type OtpResult =
| { ok: true; request: PairingRequest }
| { ok: false; reason: "not_found" | "expired" | "locked" | "wrong" };
Security Details
Pairing Code
| Property | Value |
|---|---|
| Alphabet | ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 chars) |
| Excluded | O, 0, I, 1 (ambiguity avoidance) |
| Length | 6 characters |
| Entropy | ~30 bits (32^6 = 1,073,741,824 combinations) |
| Generation | crypto.randomBytes, modulo-mapped to alphabet |
| Uniqueness | Checked against all existing codes; retries up to 100 times |
| TTL | 1 hour (60 * 60 * 1000 ms) |
| Case | Case-insensitive lookup (.toUpperCase()) |
OTP
| Property | Value |
|---|---|
| Format | 5-digit numeric (10000-99999) |
| Generation | crypto.randomInt |
| TTL | 5 minutes (5 * 60 * 1000 ms) |
| Max attempts | 5 (MAX_OTP_ATTEMPTS) |
| Brute-force | After 5 wrong attempts, request is deleted and user must start over |
| Input matching | Exact string match after .trim() |
| Regex gate | Middleware only calls verifyOtp if input matches /^\d{5}$/ |
General
- Max pending requests: 10 (
MAX_PENDING). If the cap is reached, the oldest requests are evicted. - Pruning: Expired requests are pruned on every read. Pending requests expire after 1 hour; OTP-pending requests expire after 5 minutes from OTP creation.
- Atomic writes:
addUserToAllowedListruns before request cleanup, so a crash between the two operations still leaves the user authorized. - Write verification: After adding a user to
allowedUsers, the config file is re-read to verify the write succeeded.
Three Approval Methods
1. Camel App (REST API)
The macOS companion app calls the gateway REST endpoints:
POST /pairing/:code/approve -> returns { ok, otp, userId, agentId }
POST /pairing/:code/deny -> returns { ok }
On approval, the endpoint also calls notifyUserOtpRequired() to message the user in Telegram. The OTP is returned in the response so the app can display it to the admin.
2. CLI Command (camelagi pairing)
Interactive terminal command that lists all pending requests and prompts the admin to approve or deny each one:
$ camelagi pairing
Request: H7KM3R
User: @johndoe (123456789)
Agent: assistant
Status: pending
Approve? (y/n): y
Approved. OTP: 48271
Tell the user to enter this code in the bot chat.
For requests already in otp_pending status, it displays the existing OTP and notes that it is waiting for the user.
3. Admin Bot /pairing Command
The admin bot’s /pairing command lists all pending requests with inline Approve/Deny buttons. When the admin taps a button:
- Approve: Calls
approveRequest(code), edits the message to show the OTP, and sendsnotifyUserOtpRequired()to the user. - Deny: Calls
denyRequest(code), edits the message to show “Denied”, and sendsnotifyUserOfDenial()to the user.
Additionally, when an agent bot creates a pairing request, notifyAdminOfPairing() proactively sends the request to all admin bot users with inline buttons, so admins do not need to run /pairing manually.
Admin Bot Middleware
File: src/telegram/admin-bot.ts (lines 88-182)
The admin bot always requires authorization (there is no “open access” mode). The isUserAllowed() function uses a 3-tier check:
isUserAllowed 3-Tier Check
- In-memory Set (
otpVerifiedUsers): ASet<number>populated when a user completes OTP verification during the current process lifetime. Fastest check. - In-memory config: Reads
getConfig().agents[agentId].telegram.allowedUsers. This reflects the config as loaded at startup or last reload. - File fallback: Calls
loadConfig()to readconfig.yamlfrom disk. This catches cases where another process (e.g., the REST API or CLI) approved a user after the bot started. If the user is found here, their ID is added tootpVerifiedUsersfor future fast lookups.
Middleware Flow
Message received
|
v
isUserAllowed(userId)?
|-- YES --> next() (process message normally)
|-- NO
|
v
Is group chat? --> YES --> silent reject (return)
|-- NO
v
hasPendingRequest(userId, agentId)?
|
|-- status: "otp_pending"
| |-- input is 5-digit number? --> verifyOtp()
| | |-- ok: true --> add to otpVerifiedUsers, reply "Verification complete", next()
| | |-- wrong/expired/locked --> reply error message
| |-- not 5-digit --> reply "Enter the 5-digit verification code"
|
|-- status: "pending"
| --> reply "Your access request is pending. Code: XXXXXX"
|
|-- no pending request
--> createPairingRequest()
--> reply "Access requested. Code: XXXXXX"
Agent Bot Middleware
File: src/telegram/agent-bot.ts (lines 66-148)
The agent bot middleware is structurally similar to the admin bot but has one key difference:
Open Access Mode
if (agent.allowedUsers.length === 0) { await next(); return; }
If allowedUsers is an empty array (or not set), the agent bot allows all users without any pairing or OTP check. This is the “open access” mode for public bots.
isUserAllowed 3-Tier Check
Same pattern as the admin bot:
- In-memory Set (
otpVerifiedUsers) - In-memory config (
getAgent(0).allowedUsers) - File fallback (
loadConfig(), checking bothtelegram.allowedUsersandagents[agentId].telegram.allowedUsers)
Additional Behavior
When an agent bot creates a pairing request for an unauthorized user, it also calls notifyAdminOfPairing() to push the request to all admin bots with inline Approve/Deny buttons. The admin bot does not do this (since it is the admin interface itself).
Bot Approval Flow
File: src/telegram/bot-approval.ts
This is a separate system from user pairing. Bot approval controls whether a new agent’s Telegram bot is allowed to start polling.
Purpose
When a new agent with a botToken is configured, it does not automatically start. Instead, it enters a pending approval queue. An admin must approve the bot before it begins polling Telegram.
Data Model
interface BotApproval {
agentId: string;
agentName: string;
botToken: string;
botUsername?: string;
model?: string;
requestedAt: number;
}
Storage
Stored in ~/.camelagi/bot-approvals.json as a JSON array.
Operations
| Function | Description |
|---|---|
requestBotApproval() |
Add a bot to the pending queue (replaces if already pending) |
listPendingBotApprovals() |
List all pending bot approvals |
approveBotApproval(agentId) |
Remove from pending and return the approval object |
denyBotApproval(agentId) |
Remove from pending |
hasPendingBotApproval(agentId) |
Check if an agent has a pending approval |
REST Endpoints
GET /bot-approvals -> list pending
POST /bot-approvals/:agentId/approve -> approve and start bot
POST /bot-approvals/:agentId/deny -> deny and discard
On approval via REST, the gateway dynamically imports startBot() from telegram.js and launches the bot immediately.
Notification System
File: src/telegram/pairing-notify.ts
Three notification functions handle communication between admin bots and user-facing agent bots:
notifyAdminOfPairing(request, config, activeBots)
- Triggered when an agent bot creates a new pairing request.
- Iterates over all agents with
admin: truein the config. - For each admin agent, sends a message to every user in that agent’s
allowedUserslist. - Message includes: user label (
@usernameor first name or user ID), agent ID, and pairing code. - Includes an
InlineKeyboardwith Approve and Deny buttons (pairing:approve:CODE/pairing:deny:CODE). - Errors are silently caught (admin may not have started the bot yet).
notifyUserOtpRequired(request, activeBots)
- Called after a request is approved (from REST endpoint or admin bot callback).
- Sends a message to the user’s
chatIdvia the agent bot they originally messaged. - Message: “Your request has been approved. Please enter the 5-digit verification code to complete access.”
notifyUserOfDenial(request, activeBots)
- Called after a request is denied.
- Sends “Access denied.” to the user’s chat via the agent bot.
Storage Format
~/.camelagi/pairing.json
JSON array of PairingRequest objects:
[
{
"code": "H7KM3R",
"userId": 123456789,
"username": "johndoe",
"firstName": "John",
"agentId": "assistant",
"chatId": 123456789,
"requestedAt": 1710300000000,
"status": "pending"
},
{
"code": "NW4P8T",
"userId": 987654321,
"username": "jane",
"firstName": "Jane",
"agentId": "assistant",
"chatId": 987654321,
"requestedAt": 1710300060000,
"status": "otp_pending",
"otp": "48271",
"otpCreatedAt": 1710300120000,
"otpAttempts": 1
}
]
PairingRequest Interface
| Field | Type | Description |
|---|---|---|
code |
string |
6-char alphanumeric pairing code |
userId |
number |
Telegram user ID |
username |
string? |
Telegram @username |
firstName |
string? |
Telegram first name |
agentId |
string |
Which agent the user is requesting access to |
chatId |
number |
Telegram chat ID (for sending notifications) |
requestedAt |
number |
Unix timestamp (ms) of request creation |
status |
PairingStatus |
"pending", "otp_pending", or "completed" |
otp |
string? |
5-digit OTP (set on approval) |
otpCreatedAt |
number? |
Unix timestamp (ms) of OTP generation |
otpAttempts |
number? |
Count of failed OTP verification attempts |
~/.camelagi/bot-approvals.json
JSON array of BotApproval objects (see Bot Approval Flow).
~/.camelagi/config.yaml
Pairing completion writes to the allowedUsers array within the relevant config section:
- For
agentId === "telegram":telegram.allowedUsers - For named agents:
agents.<agentId>.telegram.allowedUsers
REST API Endpoints
All endpoints require authentication via the Authorization header (checked by checkAuth).
User Pairing
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /pairing |
List pending requests | PairingRequest[] |
| POST | /pairing/:code/approve |
Approve a pending request | { ok, otp, userId, agentId } or 404 |
| POST | /pairing/:code/deny |
Deny a pending request | { ok } or 404 |
Approve side effects: Generates OTP, sets status to otp_pending, sends Telegram notification to the user via notifyUserOtpRequired().
Deny side effects: Removes the request, sends “Access denied.” to the user via notifyUserOfDenial().
Bot Approvals
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /bot-approvals |
List pending bot approvals | BotApproval[] |
| POST | /bot-approvals/:agentId/approve |
Approve and start bot | { ok, agentId, botUsername } or 404 |
| POST | /bot-approvals/:agentId/deny |
Deny bot approval | { ok } or 404 |
CLI Usage
camelagi pairing
Lists all pending pairing requests interactively. For each request with status "pending", prompts:
Request: H7KM3R
User: @johndoe (123456789)
Agent: assistant
Status: pending
Approve? (y/n):
- Entering
ycallsapproveRequest()and displays the generated OTP. - Entering anything else calls
denyRequest().
For requests with status "otp_pending", it displays the existing OTP and notes the system is waiting for the user:
Request: NW4P8T
User: @jane (987654321)
Agent: assistant
Status: otp_pending [OTP: 48271]
(Waiting for user to enter OTP in bot chat)
If there are no pending requests, it prints “No pending pairing requests.” and exits.
Source Files
| File | Purpose |
|---|---|
src/telegram/pairing.ts |
Core pairing logic, OTP generation/verification, storage |
src/telegram/pairing-notify.ts |
Telegram notification helpers |
src/telegram/bot-approval.ts |
Bot-level approval queue |
src/telegram/admin-bot.ts |
Admin bot middleware + /pairing command + inline callbacks |
src/telegram/agent-bot.ts |
Agent bot middleware (with open-access mode) |
src/gateway/routes.ts |
REST API endpoints for pairing and bot approvals |
src/cli/cmd-pairing.ts |
CLI command for terminal-based approval |